Compare commits

..

14 Commits

Author SHA1 Message Date
9c1d3bc04c KI-AGENT: Selfhost Setup für Node Exporter ergänzen
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 19s
Build and Push Docker Images / build-frontend (push) Successful in 53s
Build and Push Docker Images / build-website (push) Successful in 20s
Build and Push Docker Images / build-docs (push) Successful in 11s
2026-05-20 21:03:12 +02:00
8df587f9e2 KI-AGENT: Systemstatus und Node Exporter ergänzen 2026-05-20 20:41:48 +02:00
3796bc2953 KI-AGENT: Mobile Matrix-Kommunikation vollständig integrieren 2026-05-20 20:38:48 +02:00
2278dfa714 KI-AGENT: Call UX im Chat verbessern 2026-05-20 20:32:02 +02:00
1a5c69fcfb KI-AGENT: Live-Sync und Nachrichtenaktionen im Chat ergänzen 2026-05-20 20:27:38 +02:00
a671ae392d KI-AGENT: Mitgliederverwaltung und Suche im Chat umsetzen 2026-05-20 20:21:18 +02:00
4c58d175a0 KI-AGENT: Chat Anhänge und Nachrichteninteraktionen abrunden 2026-05-20 20:12:02 +02:00
bc655f0e06 KI-AGENT: Mobile Version auf 2.0.0 anheben
- App-Version auf 2.0.0 gesetzt
- iOS Buildnummer auf 3 gesetzt
- Package-Lock auf neue Mobile-Version aktualisiert
2026-05-20 20:09:13 +02:00
22bcf01fa8 KI-AGENT: Mobile Bundle-ID für TestFlight anpassen
- iOS Bundle Identifier und Android Package auf software.federspiel.fedeo gesetzt
- TestFlight-Dokumentation aktualisiert
- Vertriebs-Screenshots des Mobile-Dashboards ergänzt
2026-05-20 20:05:21 +02:00
bf8a3386d7 KI-AGENT: Mobile TestFlight Build vorbereiten
- EAS-Profil und Scripts für TestFlight ergänzt
- Node 22 für Mobile-Builds festgelegt und README aktualisiert
- Expo-SDK-Abhängigkeiten für expo-doctor angeglichen
2026-05-20 19:44:24 +02:00
d182231448 KI-AGENT: Selfhost-Codeblock zentriert 2026-05-19 22:41:11 +02:00
0a32ae77cd KI-AGENT: Selfhost-Codeblock schmaler ausgerichtet 2026-05-19 22:35:52 +02:00
98c95483d8 KI-AGENT: Selfhost-Script als Codeblock eingebaut 2026-05-19 22:32:21 +02:00
bcde1da84f KI-AGENT: Zeige absoluten Compose Pfad im Selfhost Setup 2026-05-19 22:19:32 +02:00
29 changed files with 3661 additions and 418 deletions

View File

@@ -53,6 +53,9 @@ OPENAI_API_KEY=replace-this
STIRLING_API_KEY=replace-this STIRLING_API_KEY=replace-this
NUXT_PUBLIC_PDF_LICENSE=replace-with-your-pdf-license NUXT_PUBLIC_PDF_LICENSE=replace-with-your-pdf-license
# Interner Prometheus Node Exporter für die Admin-Systemstatusseite.
NODE_EXPORTER_URL=http://node-exporter:9100
# Optionaler Erststart-Bootstrap. Wenn gesetzt, werden Admin, Mandant, # Optionaler Erststart-Bootstrap. Wenn gesetzt, werden Admin, Mandant,
# Admin-Rolle und Grunddaten beim Backend-Start idempotent angelegt. # Admin-Rolle und Grunddaten beim Backend-Start idempotent angelegt.
FEDEO_BOOTSTRAP_ADMIN_EMAIL=admin@example.com FEDEO_BOOTSTRAP_ADMIN_EMAIL=admin@example.com

View File

@@ -17,6 +17,7 @@ type MatrixRoomEvent = {
sender: string sender: string
origin_server_ts: number origin_server_ts: number
type: string type: string
redacts?: string
content?: { content?: {
body?: string body?: string
msgtype?: string msgtype?: string
@@ -25,6 +26,18 @@ type MatrixRoomEvent = {
mimetype?: string mimetype?: string
size?: number size?: number
} }
"m.new_content"?: {
body?: string
msgtype?: string
}
"m.relates_to"?: {
event_id?: string
key?: string
rel_type?: string
"m.in_reply_to"?: {
event_id?: string
}
}
} }
} }
@@ -35,6 +48,33 @@ type MatrixJoinedMembersResponse = {
}> }>
} }
type MatrixRoomSearchResponse = {
search_categories?: {
room_events?: {
count?: number
results?: Array<{
result?: MatrixRoomEvent & {
room_id?: string
}
}>
}
}
}
type MatrixSyncResponse = {
next_batch?: string
rooms?: {
join?: Record<string, {
timeline?: {
events?: MatrixRoomEvent[]
}
state?: {
events?: MatrixRoomEvent[]
}
}>
}
}
type MatrixUserSession = { type MatrixUserSession = {
accessToken: string accessToken: string
matrixUserId: string matrixUserId: string
@@ -71,6 +111,10 @@ type MatrixAttachmentInput = {
size: number size: number
} }
type MatrixMessageOptions = {
replyToEventId?: string
}
type MatrixCachedValue<T = any> = { type MatrixCachedValue<T = any> = {
exists: true exists: true
cachedUntil: number cachedUntil: number
@@ -1158,27 +1202,72 @@ export function matrixService(server: FastifyInstance) {
session.accessToken session.accessToken
) )
const replacementByEventId = new Map<string, MatrixRoomEvent>()
const reactionsByEventId = new Map<string, Map<string, { key: string; count: number; own: boolean }>>()
for (const event of response.chunk) {
const relation = event.content?.["m.relates_to"]
if (
event.type === "m.room.message" &&
relation?.rel_type === "m.replace" &&
relation.event_id
) {
replacementByEventId.set(relation.event_id, event)
}
if (
event.type === "m.reaction" &&
relation?.rel_type === "m.annotation" &&
relation.event_id &&
relation.key
) {
const eventReactions = reactionsByEventId.get(relation.event_id) || new Map()
const reaction = eventReactions.get(relation.key) || {
key: relation.key,
count: 0,
own: false,
}
reaction.count += 1
reaction.own = reaction.own || event.sender === session.matrixUserId
eventReactions.set(relation.key, reaction)
reactionsByEventId.set(relation.event_id, eventReactions)
}
}
const messages = response.chunk
.filter((event) =>
event.type === "m.room.message" &&
["m.text", "m.file", "m.image"].includes(event.content?.msgtype || "") &&
event.content?.["m.relates_to"]?.rel_type !== "m.replace"
)
.map((event) => {
const replacement = replacementByEventId.get(event.event_id)
const content = replacement?.content?.["m.new_content"] || replacement?.content || event.content
return {
id: event.event_id,
sender: event.sender,
senderDisplayName: members.joined[event.sender]?.display_name || event.sender,
body: content?.body || "",
attachment: attachmentFromEvent({ ...event, content }),
timestamp: replacement?.origin_server_ts || event.origin_server_ts,
own: event.sender === session.matrixUserId,
edited: Boolean(replacement),
replyToEventId: event.content?.["m.relates_to"]?.["m.in_reply_to"]?.event_id || null,
reactions: Array.from(reactionsByEventId.get(event.event_id)?.values() || []),
}
})
.reverse()
return { return {
roomId: room.roomId, roomId: room.roomId,
alias: room.alias, alias: room.alias,
key: room.key, key: room.key,
name: room.name, name: room.name,
matrixUserId: session.matrixUserId, matrixUserId: session.matrixUserId,
messages: response.chunk messages,
.filter((event) =>
event.type === "m.room.message" &&
["m.text", "m.file", "m.image"].includes(event.content?.msgtype || "")
)
.map((event) => ({
id: event.event_id,
sender: event.sender,
senderDisplayName: members.joined[event.sender]?.display_name || event.sender,
body: event.content?.body || "",
attachment: attachmentFromEvent(event),
timestamp: event.origin_server_ts,
own: event.sender === session.matrixUserId,
}))
.reverse(),
} }
} }
@@ -1212,11 +1301,223 @@ export function matrixService(server: FastifyInstance) {
} }
} }
const searchTenantRoomMessages = async (
userId: string,
tenantId: number | null,
options: MatrixTenantRoomOptions = {},
query: string
) => {
const searchTerm = query.trim()
if (searchTerm.length < 2) {
return {
roomId: "",
alias: "",
key: options.key || "allgemein",
name: options.name || options.key || "Chat",
count: 0,
results: [],
}
}
const room = await provisionTenantRoom(userId, tenantId, options)
const session = await ensureCurrentUserJoinedRoom(userId, tenantId, {
roomId: room.roomId,
alias: room.alias,
})
const [response, members] = await Promise.all([
requestMatrixJson<MatrixRoomSearchResponse>(
"/_matrix/client/v3/search",
session.accessToken,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
search_categories: {
room_events: {
search_term: searchTerm,
keys: ["content.body"],
order_by: "recent",
filter: {
limit: 25,
rooms: [room.roomId],
},
},
},
}),
}
),
requestMatrixJson<MatrixJoinedMembersResponse>(
`/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/joined_members`,
session.accessToken
),
])
const roomEvents = response.search_categories?.room_events
const results = (roomEvents?.results || [])
.map((item) => item.result)
.filter((event): event is MatrixRoomEvent & { room_id?: string } =>
Boolean(
event?.event_id &&
event.type === "m.room.message" &&
["m.text", "m.file", "m.image"].includes(event.content?.msgtype || "")
)
)
.map((event) => ({
id: event.event_id,
roomId: event.room_id || room.roomId,
key: room.key,
sender: event.sender,
senderDisplayName: members.joined[event.sender]?.display_name || event.sender,
body: event.content?.body || "",
attachment: attachmentFromEvent(event),
timestamp: event.origin_server_ts,
own: event.sender === session.matrixUserId,
}))
return {
roomId: room.roomId,
alias: room.alias,
key: room.key,
name: room.name,
count: roomEvents?.count || results.length,
results,
}
}
const syncTenantRoomEvents = async (
userId: string,
tenantId: number | null,
options: MatrixTenantRoomOptions = {},
since?: string,
initial = false
) => {
const room = await provisionTenantRoom(userId, tenantId, options)
const session = await ensureCurrentUserJoinedRoom(userId, tenantId, {
roomId: room.roomId,
alias: room.alias,
})
const filter = {
room: {
rooms: [room.roomId],
timeline: {
limit: 30,
},
},
presence: {
types: [],
},
account_data: {
types: [],
},
}
const params = new URLSearchParams({
timeout: since && !initial ? "25000" : "0",
filter: JSON.stringify(filter),
})
if (since) params.set("since", since)
const response = await requestMatrixJson<MatrixSyncResponse>(
`/_matrix/client/v3/sync?${params.toString()}`,
session.accessToken
)
const joinedRoom = response.rooms?.join?.[room.roomId]
const timelineEvents = joinedRoom?.timeline?.events || []
const stateEvents = joinedRoom?.state?.events || []
if (initial) {
return {
roomId: room.roomId,
alias: room.alias,
key: room.key,
name: room.name,
nextBatch: response.next_batch || since || "",
messages: [],
replacements: [],
reactions: [],
redactions: [],
membersChanged: false,
}
}
const messages = timelineEvents
.filter((event) =>
event.type === "m.room.message" &&
["m.text", "m.file", "m.image"].includes(event.content?.msgtype || "") &&
event.content?.["m.relates_to"]?.rel_type !== "m.replace"
)
.map((event) => ({
id: event.event_id,
sender: event.sender,
senderDisplayName: event.sender,
body: event.content?.body || "",
attachment: attachmentFromEvent(event),
timestamp: event.origin_server_ts,
own: event.sender === session.matrixUserId,
replyToEventId: event.content?.["m.relates_to"]?.["m.in_reply_to"]?.event_id || null,
reactions: [],
}))
const replacements = timelineEvents
.filter((event) =>
event.type === "m.room.message" &&
event.content?.["m.relates_to"]?.rel_type === "m.replace" &&
Boolean(event.content?.["m.relates_to"]?.event_id)
)
.map((event) => ({
id: event.event_id,
targetEventId: event.content?.["m.relates_to"]?.event_id,
body: event.content?.["m.new_content"]?.body || event.content?.body || "",
timestamp: event.origin_server_ts,
sender: event.sender,
own: event.sender === session.matrixUserId,
}))
const reactions = timelineEvents
.filter((event) =>
event.type === "m.reaction" &&
event.content?.["m.relates_to"]?.rel_type === "m.annotation" &&
Boolean(event.content?.["m.relates_to"]?.event_id) &&
Boolean(event.content?.["m.relates_to"]?.key)
)
.map((event) => ({
id: event.event_id,
targetEventId: event.content?.["m.relates_to"]?.event_id,
key: event.content?.["m.relates_to"]?.key,
sender: event.sender,
own: event.sender === session.matrixUserId,
}))
const redactions = timelineEvents
.filter((event) => event.type === "m.room.redaction" && Boolean(event.redacts))
.map((event) => ({
id: event.event_id,
targetEventId: event.redacts,
sender: event.sender,
timestamp: event.origin_server_ts,
}))
return {
roomId: room.roomId,
alias: room.alias,
key: room.key,
name: room.name,
nextBatch: response.next_batch || since || "",
messages,
replacements,
reactions,
redactions,
membersChanged: [...timelineEvents, ...stateEvents].some((event) => event.type === "m.room.member"),
}
}
const sendTenantRoomMessage = async ( const sendTenantRoomMessage = async (
userId: string, userId: string,
tenantId: number | null, tenantId: number | null,
options: MatrixTenantRoomOptions = {}, options: MatrixTenantRoomOptions = {},
text: string text: string,
messageOptions: MatrixMessageOptions = {}
) => { ) => {
const message = text.trim() const message = text.trim()
@@ -1235,16 +1536,26 @@ export function matrixService(server: FastifyInstance) {
}) })
const txnId = `${Date.now()}-${randomBytes(8).toString("hex")}` const txnId = `${Date.now()}-${randomBytes(8).toString("hex")}`
const content: Record<string, any> = {
msgtype: "m.text",
body: message,
}
if (messageOptions.replyToEventId) {
content["m.relates_to"] = {
"m.in_reply_to": {
event_id: messageOptions.replyToEventId,
},
}
}
const response = await requestMatrixJson<{ event_id: string }>( const response = await requestMatrixJson<{ event_id: string }>(
`/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/send/m.room.message/${encodeURIComponent(txnId)}`, `/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/send/m.room.message/${encodeURIComponent(txnId)}`,
session.accessToken, session.accessToken,
{ {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify(content),
msgtype: "m.text",
body: message,
}),
} }
) )
@@ -1258,9 +1569,163 @@ export function matrixService(server: FastifyInstance) {
roomId: room.roomId, roomId: room.roomId,
alias: room.alias, alias: room.alias,
key: room.key, key: room.key,
replyToEventId: messageOptions.replyToEventId || null,
reactions: [],
} }
} }
const sendTenantRoomReaction = async (
userId: string,
tenantId: number | null,
options: MatrixTenantRoomOptions = {},
eventId: string,
key: string
) => {
const reactionKey = key.trim()
if (!eventId || !reactionKey) {
throw Object.assign(
new Error("Reaction target and key are required"),
{ statusCode: 400 }
)
}
const room = await provisionTenantRoom(userId, tenantId, options)
const session = await ensureCurrentUserJoinedRoom(userId, tenantId, {
roomId: room.roomId,
alias: room.alias,
})
const txnId = `${Date.now()}-${randomBytes(8).toString("hex")}`
await requestMatrixJson<{ event_id: string }>(
`/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/send/m.reaction/${encodeURIComponent(txnId)}`,
session.accessToken,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
"m.relates_to": {
rel_type: "m.annotation",
event_id: eventId,
key: reactionKey,
},
}),
}
)
return { success: true, eventId, key: reactionKey }
}
const editTenantRoomMessage = async (
userId: string,
tenantId: number | null,
options: MatrixTenantRoomOptions = {},
eventId: string,
text: string
) => {
const message = text.trim()
if (!eventId || !message) {
throw Object.assign(
new Error("Nachricht und Zielnachricht sind erforderlich"),
{ statusCode: 400 }
)
}
const room = await provisionTenantRoom(userId, tenantId, options)
const session = await ensureCurrentUserJoinedRoom(userId, tenantId, {
roomId: room.roomId,
alias: room.alias,
})
const txnId = `${Date.now()}-${randomBytes(8).toString("hex")}`
const response = await requestMatrixJson<{ event_id: string }>(
`/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/send/m.room.message/${encodeURIComponent(txnId)}`,
session.accessToken,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
msgtype: "m.text",
body: `* ${message}`,
"m.new_content": {
msgtype: "m.text",
body: message,
},
"m.relates_to": {
rel_type: "m.replace",
event_id: eventId,
},
}),
}
)
return {
id: response.event_id,
targetEventId: eventId,
body: message,
timestamp: Date.now(),
own: true,
}
}
const redactTenantRoomMessage = async (
userId: string,
tenantId: number | null,
options: MatrixTenantRoomOptions = {},
eventId: string
) => {
if (!eventId) {
throw Object.assign(
new Error("Zielnachricht ist erforderlich"),
{ statusCode: 400 }
)
}
const room = await provisionTenantRoom(userId, tenantId, options)
const session = await ensureCurrentUserJoinedRoom(userId, tenantId, {
roomId: room.roomId,
alias: room.alias,
})
const txnId = `${Date.now()}-${randomBytes(8).toString("hex")}`
await requestMatrixJson(
`/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/redact/${encodeURIComponent(eventId)}/${encodeURIComponent(txnId)}`,
session.accessToken,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
reason: "Nachricht in FEDEO gelöscht",
}),
}
)
return { success: true, eventId }
}
const markTenantRoomRead = async (
userId: string,
tenantId: number | null,
options: MatrixTenantRoomOptions = {},
eventId: string
) => {
if (!eventId) return { success: true, skipped: true }
const room = await provisionTenantRoom(userId, tenantId, options)
const session = await ensureCurrentUserJoinedRoom(userId, tenantId, {
roomId: room.roomId,
alias: room.alias,
})
await requestMatrixJson(
`/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/receipt/m.read/${encodeURIComponent(eventId)}`,
session.accessToken,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
}
)
return { success: true, eventId }
}
const sendTenantRoomAttachment = async ( const sendTenantRoomAttachment = async (
userId: string, userId: string,
tenantId: number | null, tenantId: number | null,
@@ -1467,13 +1932,18 @@ export function matrixService(server: FastifyInstance) {
)) ))
.where(eq(authTenantUsers.tenant_id, tenant.id)) .where(eq(authTenantUsers.tenant_id, tenant.id))
return rows const users = rows
.filter((row) => row.profileActive !== false) .filter((row) => row.profileActive !== false)
.map((row) => ({ .map((row) => ({
userId: row.userId, userId: row.userId,
email: row.email, email: row.email,
displayName: row.fullName || `${row.firstName || ""} ${row.lastName || ""}`.trim() || row.email, displayName: row.fullName || `${row.firstName || ""} ${row.lastName || ""}`.trim() || row.email,
})) }))
return await Promise.all(users.map(async (user) => ({
...user,
matrixUserId: await matrixUserIdForUser(user.userId, tenant.id),
})))
} }
const inviteMatrixUserToRoom = async ( const inviteMatrixUserToRoom = async (
@@ -1567,6 +2037,102 @@ export function matrixService(server: FastifyInstance) {
} }
} }
const inviteTenantRoomMember = async (
requestingUserId: string,
tenantId: number | null,
options: MatrixTenantRoomOptions = {},
targetUserId: string
) => {
if (!targetUserId) {
throw Object.assign(
new Error("Benutzer ist erforderlich"),
{ statusCode: 400 }
)
}
const users = await listTenantCommunicationUsers(tenantId)
const targetUser = users.find((user) => user.userId === targetUserId)
if (!targetUser) {
throw Object.assign(
new Error("Benutzer gehört nicht zum aktiven Mandanten"),
{ statusCode: 404 }
)
}
const room = await provisionTenantRoom(requestingUserId, tenantId, options)
const account = await provisionCurrentUser(targetUser.userId, tenantId)
const inviteStatus = await inviteMatrixUserToRoom(
{ roomId: room.roomId },
account.matrixUserId,
`FEDEO-Einladung: ${room.name}`
)
await ensureCurrentUserJoinedRoom(targetUser.userId, tenantId, {
roomId: room.roomId,
alias: room.alias,
})
return {
roomId: room.roomId,
alias: room.alias,
key: room.key,
name: room.name,
userId: targetUser.userId,
email: targetUser.email,
displayName: targetUser.displayName,
matrixUserId: account.matrixUserId,
status: inviteStatus === "invited" ? "joined" : inviteStatus,
ok: true,
}
}
const removeTenantRoomMember = async (
requestingUserId: string,
tenantId: number | null,
options: MatrixTenantRoomOptions = {},
matrixUserId: string
) => {
if (!matrixUserId) {
throw Object.assign(
new Error("Matrix-Benutzer ist erforderlich"),
{ statusCode: 400 }
)
}
const room = await provisionTenantRoom(requestingUserId, tenantId, options)
const session = await createAccessTokenForUser(requestingUserId, tenantId)
if (matrixUserId === session.matrixUserId) {
throw Object.assign(
new Error("Du kannst dich nicht selbst aus dem Raum entfernen"),
{ statusCode: 400 }
)
}
const serviceLogin = await ensureServiceAccessToken()
await requestMatrixJson(
`/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/kick`,
serviceLogin.accessToken,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
user_id: matrixUserId,
reason: "FEDEO-Mitgliederverwaltung",
}),
}
)
matrixJoinedRoomCache.delete(`${matrixUserId}:${room.roomId}`)
return {
success: true,
roomId: room.roomId,
alias: room.alias,
key: room.key,
matrixUserId,
}
}
const getGeneralRoomMessages = (userId: string, tenantId: number | null) => const getGeneralRoomMessages = (userId: string, tenantId: number | null) =>
getTenantRoomMessages(userId, tenantId, { getTenantRoomMessages(userId, tenantId, {
key: "allgemein", key: "allgemein",
@@ -1579,7 +2145,12 @@ export function matrixService(server: FastifyInstance) {
name: "Allgemeiner Chat", name: "Allgemeiner Chat",
}) })
const sendGeneralRoomMessage = (userId: string, tenantId: number | null, text: string) => const sendGeneralRoomMessage = (
userId: string,
tenantId: number | null,
text: string,
messageOptions: MatrixMessageOptions = {}
) =>
sendTenantRoomMessage( sendTenantRoomMessage(
userId, userId,
tenantId, tenantId,
@@ -1587,7 +2158,8 @@ export function matrixService(server: FastifyInstance) {
key: "allgemein", key: "allgemein",
name: "Allgemeiner Chat", name: "Allgemeiner Chat",
}, },
text text,
messageOptions
) )
const sendGeneralRoomAttachment = (userId: string, tenantId: number | null, attachment: MatrixAttachmentInput) => const sendGeneralRoomAttachment = (userId: string, tenantId: number | null, attachment: MatrixAttachmentInput) =>
@@ -1609,16 +2181,25 @@ export function matrixService(server: FastifyInstance) {
getTenantSpaceStatus, getTenantSpaceStatus,
provisionCurrentTenantSpace, provisionCurrentTenantSpace,
listTenantRooms, listTenantRooms,
listTenantCommunicationUsers,
getTenantRoomStatus, getTenantRoomStatus,
provisionTenantRoom, provisionTenantRoom,
createAccessTokenForUser, createAccessTokenForUser,
getTenantRoomMessages, getTenantRoomMessages,
getTenantRoomMembers, getTenantRoomMembers,
searchTenantRoomMessages,
syncTenantRoomEvents,
sendTenantRoomMessage, sendTenantRoomMessage,
sendTenantRoomReaction,
editTenantRoomMessage,
redactTenantRoomMessage,
markTenantRoomRead,
sendTenantRoomAttachment, sendTenantRoomAttachment,
getMediaContent, getMediaContent,
createElementRoomSession, createElementRoomSession,
createLiveKitRoomSession, createLiveKitRoomSession,
inviteTenantRoomMember,
removeTenantRoomMember,
syncTenantRoomMembers, syncTenantRoomMembers,
getGeneralRoomMessages, getGeneralRoomMessages,
getGeneralRoomMembers, getGeneralRoomMembers,

View File

@@ -0,0 +1,174 @@
import { FastifyInstance } from "fastify"
import { matrixService } from "./matrix.service"
type MetricSample = {
labels: Record<string, string>
value: number
}
const metricLinePattern = /^([a-zA-Z_:][a-zA-Z0-9_:]*)(?:\{([^}]*)\})?\s+(-?(?:\d+(?:\.\d+)?|\.\d+)(?:e[+-]?\d+)?|-?Inf|NaN)$/i
const nodeExporterUrl = () =>
(process.env.NODE_EXPORTER_URL || "http://node-exporter:9100").replace(/\/+$/, "")
const s3EndpointUrl = () =>
(process.env.S3_ENDPOINT || "").replace(/\/+$/, "")
const parseLabels = (value = "") => {
const labels: Record<string, string> = {}
const labelPattern = /(\w+)="((?:\\"|[^"])*)"/g
let match: RegExpExecArray | null
while ((match = labelPattern.exec(value))) {
labels[match[1]] = match[2].replace(/\\"/g, "\"")
}
return labels
}
const parsePrometheusMetrics = (text: string) => {
const metrics = new Map<string, MetricSample[]>()
for (const line of text.split("\n")) {
if (!line || line.startsWith("#")) continue
const match = line.match(metricLinePattern)
if (!match) continue
const value = Number(match[3])
if (!Number.isFinite(value)) continue
const samples = metrics.get(match[1]) || []
samples.push({
labels: parseLabels(match[2]),
value,
})
metrics.set(match[1], samples)
}
return metrics
}
const firstMetricValue = (metrics: Map<string, MetricSample[]>, name: string) =>
metrics.get(name)?.[0]?.value ?? null
const findMetricValue = (
metrics: Map<string, MetricSample[]>,
name: string,
predicate: (sample: MetricSample) => boolean
) => metrics.get(name)?.find(predicate)?.value ?? null
const serviceState = (ok: boolean, detail?: Record<string, any>) => ({
ok,
status: ok ? "ok" : "error",
...detail,
})
const checkHttp = async (url: string, timeoutMs = 3000) => {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), timeoutMs)
try {
const response = await fetch(url, { signal: controller.signal })
return serviceState(response.ok, {
httpStatus: response.status,
url,
})
} catch (err: any) {
return serviceState(false, {
url,
error: err?.message || "HTTP-Abfrage fehlgeschlagen",
})
} finally {
clearTimeout(timeout)
}
}
export const buildSystemStatus = async (server: FastifyInstance) => {
const checkedAt = new Date()
const nodeExporterMetricsUrl = `${nodeExporterUrl()}/metrics`
let nodeMetrics: Map<string, MetricSample[]> | null = null
let nodeExporterError: string | null = null
try {
const response = await fetch(nodeExporterMetricsUrl)
if (!response.ok) {
throw new Error(`Node Exporter antwortet mit ${response.status}`)
}
nodeMetrics = parsePrometheusMetrics(await response.text())
} catch (err: any) {
nodeExporterError = err?.message || "Node Exporter nicht erreichbar"
}
const memoryTotal = nodeMetrics ? firstMetricValue(nodeMetrics, "node_memory_MemTotal_bytes") : null
const memoryAvailable = nodeMetrics ? firstMetricValue(nodeMetrics, "node_memory_MemAvailable_bytes") : null
const rootSize = nodeMetrics
? findMetricValue(nodeMetrics, "node_filesystem_size_bytes", (sample) => sample.labels.mountpoint === "/")
: null
const rootAvailable = nodeMetrics
? findMetricValue(nodeMetrics, "node_filesystem_avail_bytes", (sample) => sample.labels.mountpoint === "/")
: null
const bootTime = nodeMetrics ? firstMetricValue(nodeMetrics, "node_boot_time_seconds") : null
const cpuCount = nodeMetrics
? new Set((nodeMetrics.get("node_cpu_seconds_total") || [])
.filter((sample) => sample.labels.mode === "idle")
.map((sample) => sample.labels.cpu)).size
: null
const uname = nodeMetrics?.get("node_uname_info")?.[0]?.labels || null
const databaseCheck = await server.db.execute("SELECT NOW() as now")
const matrixStatus = await matrixService(server).getStatus().catch((err: any) => ({
reachable: false,
error: err?.message || "Matrix-Status nicht verfügbar",
}))
const minioUrl = s3EndpointUrl()
return {
checkedAt: checkedAt.toISOString(),
backend: {
status: "ok",
uptimeSeconds: Math.round(process.uptime()),
nodeVersion: process.version,
environment: process.env.NODE_ENV || "development",
},
server: {
status: nodeMetrics ? "ok" : "unavailable",
nodeExporterUrl: nodeExporterMetricsUrl,
error: nodeExporterError,
hostname: uname?.nodename || null,
kernel: uname?.release || null,
cpuCount,
load: {
one: nodeMetrics ? firstMetricValue(nodeMetrics, "node_load1") : null,
five: nodeMetrics ? firstMetricValue(nodeMetrics, "node_load5") : null,
fifteen: nodeMetrics ? firstMetricValue(nodeMetrics, "node_load15") : null,
},
memory: {
totalBytes: memoryTotal,
availableBytes: memoryAvailable,
usedBytes: memoryTotal !== null && memoryAvailable !== null ? memoryTotal - memoryAvailable : null,
usedPercent: memoryTotal ? Math.round(((memoryTotal - (memoryAvailable || 0)) / memoryTotal) * 1000) / 10 : null,
},
disk: {
rootTotalBytes: rootSize,
rootAvailableBytes: rootAvailable,
rootUsedBytes: rootSize !== null && rootAvailable !== null ? rootSize - rootAvailable : null,
rootUsedPercent: rootSize ? Math.round(((rootSize - (rootAvailable || 0)) / rootSize) * 1000) / 10 : null,
},
uptimeSeconds: bootTime ? Math.max(0, Math.round(Date.now() / 1000 - bootTime)) : null,
},
services: {
database: serviceState(true, {
checkedAt: String(databaseCheck.rows?.[0]?.now || checkedAt.toISOString()),
}),
nodeExporter: serviceState(Boolean(nodeMetrics), {
url: nodeExporterMetricsUrl,
error: nodeExporterError,
}),
matrix: serviceState(Boolean((matrixStatus as any).reachable), matrixStatus as Record<string, any>),
minio: minioUrl ? await checkHttp(`${minioUrl}/minio/health/live`) : serviceState(false, {
error: "S3_ENDPOINT ist nicht gesetzt",
}),
},
}
}

View File

@@ -17,6 +17,7 @@ import { sendMail } from "../utils/mailer";
import { ensureTenantBaseData } from "../modules/bootstrap.service"; import { ensureTenantBaseData } from "../modules/bootstrap.service";
import { buildTenantFullExport, importTenantFullExport } from "../utils/tenantFullExport"; import { buildTenantFullExport, importTenantFullExport } from "../utils/tenantFullExport";
import type { TenantFullExport } from "../utils/tenantFullExport"; import type { TenantFullExport } from "../utils/tenantFullExport";
import { buildSystemStatus } from "../modules/system-status.service";
export default async function adminRoutes(server: FastifyInstance) { export default async function adminRoutes(server: FastifyInstance) {
const deriveNameFromEmail = (email: string) => { const deriveNameFromEmail = (email: string) => {
@@ -393,6 +394,21 @@ export default async function adminRoutes(server: FastifyInstance) {
} }
}); });
// -------------------------------------------------------------
// GET /admin/system-status
// -------------------------------------------------------------
server.get("/admin/system-status", async (req, reply) => {
try {
const currentUser = await requireAdmin(req, reply);
if (!currentUser) return;
return await buildSystemStatus(server);
} catch (err) {
console.error("ERROR /admin/system-status:", err);
return reply.code(500).send({ error: "Internal Server Error" });
}
});
// ------------------------------------------------------------- // -------------------------------------------------------------
// POST /admin/users // POST /admin/users
// ------------------------------------------------------------- // -------------------------------------------------------------

View File

@@ -603,6 +603,15 @@ export default async function communicationRoutes(server: FastifyInstance) {
} }
}) })
server.get("/communication/matrix/users", async (req, reply) => {
try {
const users = await matrix.listTenantCommunicationUsers(req.user.tenant_id)
return { users }
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix users failed")
}
})
server.post("/communication/matrix/rooms/general/session", async (req, reply) => { server.post("/communication/matrix/rooms/general/session", async (req, reply) => {
try { try {
return await matrix.createElementRoomSession(req.user.user_id, req.user.tenant_id, { return await matrix.createElementRoomSession(req.user.user_id, req.user.tenant_id, {
@@ -641,8 +650,10 @@ export default async function communicationRoutes(server: FastifyInstance) {
server.post("/communication/matrix/rooms/general/messages", async (req, reply) => { server.post("/communication/matrix/rooms/general/messages", async (req, reply) => {
try { try {
const body = req.body as { text?: string } const body = req.body as { text?: string; replyToEventId?: string }
const message = await matrix.sendGeneralRoomMessage(req.user.user_id, req.user.tenant_id, body.text || "") const message = await matrix.sendGeneralRoomMessage(req.user.user_id, req.user.tenant_id, body.text || "", {
replyToEventId: body.replyToEventId,
})
const room = await matrix.getTenantRoomStatus(req.user.tenant_id, "allgemein", "Allgemeiner Chat") const room = await matrix.getTenantRoomStatus(req.user.tenant_id, "allgemein", "Allgemeiner Chat")
await notifyUsersAboutChatMessage(req, room, message, body.text || "") await notifyUsersAboutChatMessage(req, room, message, body.text || "")
return message return message
@@ -688,7 +699,12 @@ export default async function communicationRoutes(server: FastifyInstance) {
try { try {
if (!req.user.tenant_id) return reply.code(400).send({ error: "Kein aktiver Mandant" }) if (!req.user.tenant_id) return reply.code(400).send({ error: "Kein aktiver Mandant" })
const params = req.params as { roomKey: string } const params = req.params as { roomKey: string }
return await markRoomNotificationsRead(req.user.tenant_id, req.user.user_id, params.roomKey) const body = (req.body || {}) as { eventId?: string }
const result = await markRoomNotificationsRead(req.user.tenant_id, req.user.user_id, params.roomKey)
if (body.eventId) {
await matrix.markTenantRoomRead(req.user.user_id, req.user.tenant_id, roomOptionsFromRequest(req), body.eventId)
}
return result
} catch (err: any) { } catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix room read state failed") return handleMatrixError(req, reply, err, "Matrix room read state failed")
} }
@@ -706,6 +722,35 @@ export default async function communicationRoutes(server: FastifyInstance) {
} }
}) })
server.get("/communication/matrix/rooms/:roomKey/search", async (req, reply) => {
try {
const query = req.query as { q?: string }
return await matrix.searchTenantRoomMessages(
req.user.user_id,
req.user.tenant_id,
roomOptionsFromRequest(req),
query.q || ""
)
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix search failed")
}
})
server.get("/communication/matrix/rooms/:roomKey/sync", async (req, reply) => {
try {
const query = req.query as { since?: string; initial?: string }
return await matrix.syncTenantRoomEvents(
req.user.user_id,
req.user.tenant_id,
roomOptionsFromRequest(req),
query.since,
query.initial === "1"
)
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix sync failed")
}
})
server.get("/communication/matrix/rooms/:roomKey/members", async (req, reply) => { server.get("/communication/matrix/rooms/:roomKey/members", async (req, reply) => {
try { try {
return await matrix.getTenantRoomMembers( return await matrix.getTenantRoomMembers(
@@ -718,6 +763,34 @@ export default async function communicationRoutes(server: FastifyInstance) {
} }
}) })
server.post("/communication/matrix/rooms/:roomKey/members/invite", async (req, reply) => {
try {
const body = req.body as { userId?: string }
return await matrix.inviteTenantRoomMember(
req.user.user_id,
req.user.tenant_id,
roomOptionsFromRequest(req),
body.userId || ""
)
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix member invite failed")
}
})
server.delete("/communication/matrix/rooms/:roomKey/members/:matrixUserId", async (req, reply) => {
try {
const params = req.params as { matrixUserId: string }
return await matrix.removeTenantRoomMember(
req.user.user_id,
req.user.tenant_id,
roomOptionsFromRequest(req),
params.matrixUserId
)
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix member remove failed")
}
})
server.post("/communication/matrix/rooms/:roomKey/session", async (req, reply) => { server.post("/communication/matrix/rooms/:roomKey/session", async (req, reply) => {
try { try {
return await matrix.createElementRoomSession( return await matrix.createElementRoomSession(
@@ -759,12 +832,15 @@ export default async function communicationRoutes(server: FastifyInstance) {
server.post("/communication/matrix/rooms/:roomKey/messages", async (req, reply) => { server.post("/communication/matrix/rooms/:roomKey/messages", async (req, reply) => {
try { try {
const body = req.body as { text?: string } const body = req.body as { text?: string; replyToEventId?: string }
const message = await matrix.sendTenantRoomMessage( const message = await matrix.sendTenantRoomMessage(
req.user.user_id, req.user.user_id,
req.user.tenant_id, req.user.tenant_id,
roomOptionsFromRequest(req), roomOptionsFromRequest(req),
body.text || "" body.text || "",
{
replyToEventId: body.replyToEventId,
}
) )
const room = await matrix.getTenantRoomStatus(req.user.tenant_id, message.key) const room = await matrix.getTenantRoomStatus(req.user.tenant_id, message.key)
await notifyUsersAboutChatMessage(req, room, message, body.text || "") await notifyUsersAboutChatMessage(req, room, message, body.text || "")
@@ -774,6 +850,52 @@ export default async function communicationRoutes(server: FastifyInstance) {
} }
}) })
server.post("/communication/matrix/rooms/:roomKey/messages/:eventId/reactions", async (req, reply) => {
try {
const params = req.params as { eventId: string }
const body = req.body as { key?: string }
return await matrix.sendTenantRoomReaction(
req.user.user_id,
req.user.tenant_id,
roomOptionsFromRequest(req),
params.eventId,
body.key || ""
)
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix reaction send failed")
}
})
server.put("/communication/matrix/rooms/:roomKey/messages/:eventId", async (req, reply) => {
try {
const params = req.params as { eventId: string }
const body = req.body as { text?: string }
return await matrix.editTenantRoomMessage(
req.user.user_id,
req.user.tenant_id,
roomOptionsFromRequest(req),
params.eventId,
body.text || ""
)
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix message edit failed")
}
})
server.delete("/communication/matrix/rooms/:roomKey/messages/:eventId", async (req, reply) => {
try {
const params = req.params as { eventId: string }
return await matrix.redactTenantRoomMessage(
req.user.user_id,
req.user.tenant_id,
roomOptionsFromRequest(req),
params.eventId
)
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix message delete failed")
}
})
server.post("/communication/matrix/rooms/:roomKey/attachments", async (req, reply) => { server.post("/communication/matrix/rooms/:roomKey/attachments", async (req, reply) => {
try { try {
const attachment = await uploadedAttachmentFromRequest(req) const attachment = await uploadedAttachmentFromRequest(req)

View File

@@ -81,8 +81,7 @@ services:
- internal - internal
backend: backend:
build: image: git.federspiel.tech/flfeders/fedeo/backend:dev
context: ./backend
container_name: fedeo-backend container_name: fedeo-backend
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
@@ -139,6 +138,7 @@ services:
MATRIX_SERVICE_USER_LOCALPART: ${MATRIX_SERVICE_USER_LOCALPART:-fedeo_service} MATRIX_SERVICE_USER_LOCALPART: ${MATRIX_SERVICE_USER_LOCALPART:-fedeo_service}
LIVEKIT_KEY: ${LIVEKIT_KEY:-fedeo-livekit} LIVEKIT_KEY: ${LIVEKIT_KEY:-fedeo-livekit}
LIVEKIT_SECRET: ${LIVEKIT_SECRET:-change-this-livekit-secret-please-replace} LIVEKIT_SECRET: ${LIVEKIT_SECRET:-change-this-livekit-secret-please-replace}
NODE_EXPORTER_URL: ${NODE_EXPORTER_URL:-http://node-exporter:9100}
labels: labels:
- traefik.enable=true - traefik.enable=true
- traefik.http.routers.fedeo-backend.rule=Host(`${DOMAIN}`) && PathPrefix(`/backend`) - traefik.http.routers.fedeo-backend.rule=Host(`${DOMAIN}`) && PathPrefix(`/backend`)
@@ -152,9 +152,25 @@ services:
- web - web
- internal - internal
node-exporter:
image: prom/node-exporter:v1.8.2
container_name: fedeo-node-exporter
restart: unless-stopped
command:
- --path.procfs=/host/proc
- --path.sysfs=/host/sys
- --path.rootfs=/rootfs
- --collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)
pid: host
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/rootfs:ro,rslave
networks:
- internal
frontend: frontend:
build: image: git.federspiel.tech/flfeders/fedeo/frontend:dev
context: ./frontend
container_name: fedeo-frontend container_name: fedeo-frontend
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:

View File

@@ -56,6 +56,7 @@ services:
- WEB_PUSH_PUBLIC_KEY=${WEB_PUSH_PUBLIC_KEY:-} - WEB_PUSH_PUBLIC_KEY=${WEB_PUSH_PUBLIC_KEY:-}
- WEB_PUSH_PRIVATE_KEY=${WEB_PUSH_PRIVATE_KEY:-} - WEB_PUSH_PRIVATE_KEY=${WEB_PUSH_PRIVATE_KEY:-}
- WEB_PUSH_SUBJECT=${WEB_PUSH_SUBJECT:-mailto:admin@example.com} - WEB_PUSH_SUBJECT=${WEB_PUSH_SUBJECT:-mailto:admin@example.com}
- NODE_EXPORTER_URL=${NODE_EXPORTER_URL:-http://node-exporter:9100}
networks: networks:
- traefik - traefik
labels: labels:
@@ -74,6 +75,23 @@ services:
- "traefik.http.routers.fedeo-backend-secure.entrypoints=web-secured" # - "traefik.http.routers.fedeo-backend-secure.entrypoints=web-secured" #
- "traefik.http.routers.fedeo-backend-secure.tls.certresolver=mytlschallenge" - "traefik.http.routers.fedeo-backend-secure.tls.certresolver=mytlschallenge"
- "traefik.http.routers.fedeo-backend-secure.middlewares=fedeo-backend-strip" - "traefik.http.routers.fedeo-backend-secure.middlewares=fedeo-backend-strip"
node-exporter:
image: prom/node-exporter:v1.8.2
restart: unless-stopped
command:
- --path.procfs=/host/proc
- --path.sysfs=/host/sys
- --path.rootfs=/rootfs
- --collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)
pid: host
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/rootfs:ro,rslave
networks:
- traefik
matrix-db: matrix-db:
image: postgres:16-alpine image: postgres:16-alpine
restart: unless-stopped restart: unless-stopped

View File

@@ -355,6 +355,11 @@ const links = computed(() => {
to: "/administration/tenants", to: "/administration/tenants",
icon: "i-heroicons-building-office-2", icon: "i-heroicons-building-office-2",
}, },
{
label: "Systemstatus",
to: "/administration/system",
icon: "i-heroicons-server-stack",
},
] : [] ] : []
const visibleOrganisationChildren = visibleItems(organisationChildren) const visibleOrganisationChildren = visibleItems(organisationChildren)

View File

@@ -55,6 +55,34 @@ export type TenantImportResult = {
files: { restored: number; skipped: number } files: { restored: number; skipped: number }
} }
export type SystemStatus = {
checkedAt: string
backend: {
status: string
uptimeSeconds: number
nodeVersion: string
environment: string
}
server: {
status: string
nodeExporterUrl: string
error?: string | null
hostname?: string | null
kernel?: string | null
cpuCount?: number | null
uptimeSeconds?: number | null
load: { one?: number | null; five?: number | null; fifteen?: number | null }
memory: { totalBytes?: number | null; availableBytes?: number | null; usedBytes?: number | null; usedPercent?: number | null }
disk: { rootTotalBytes?: number | null; rootAvailableBytes?: number | null; rootUsedBytes?: number | null; rootUsedPercent?: number | null }
}
services: Record<string, {
ok: boolean
status: string
error?: string | null
[key: string]: any
}>
}
export const useAdmin = () => { export const useAdmin = () => {
const { $api } = useNuxtApp() const { $api } = useNuxtApp()
@@ -130,8 +158,13 @@ export const useAdmin = () => {
}) })
} }
const getSystemStatus = async (): Promise<SystemStatus> => {
return await $api("/api/admin/system-status")
}
return { return {
getOverview, getOverview,
getSystemStatus,
createUser, createUser,
createUserForProfile, createUserForProfile,
updateUser, updateUser,

View File

@@ -0,0 +1,228 @@
<script setup lang="ts">
import type { SystemStatus } from "~/composables/useAdmin"
const auth = useAuthStore()
const toast = useToast()
const router = useRouter()
const admin = useAdmin()
const loading = ref(true)
const status = ref<SystemStatus | null>(null)
const serviceLabels: Record<string, string> = {
backend: "Backend",
database: "Datenbank",
nodeExporter: "Node Exporter",
matrix: "Matrix",
minio: "Dateispeicher",
}
const formatBytes = (value?: number | null) => {
const bytes = Number(value || 0)
if (!bytes) return "-"
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`
}
const formatDuration = (seconds?: number | null) => {
const value = Number(seconds || 0)
if (!value) return "-"
const days = Math.floor(value / 86400)
const hours = Math.floor((value % 86400) / 3600)
const minutes = Math.floor((value % 3600) / 60)
if (days) return `${days} d ${hours} h`
if (hours) return `${hours} h ${minutes} min`
return `${minutes} min`
}
const loadStatus = async () => {
loading.value = true
try {
status.value = await admin.getSystemStatus()
} catch (err: any) {
console.error("[administration/system]", err)
toast.add({
title: "Systemstatus konnte nicht geladen werden",
description: err?.data?.error || err?.message || "Unbekannter Fehler",
color: "red",
})
} finally {
loading.value = false
}
}
const serviceRows = computed(() => {
const services = status.value?.services || {}
return Object.entries(services).map(([key, service]) => ({
key,
label: serviceLabels[key] || key,
...service,
}))
})
const overallStatus = computed(() => {
if (!status.value) return "unavailable"
return serviceRows.value.every((service) => service.ok) ? "ok" : "warning"
})
onMounted(async () => {
if (!auth.user?.is_admin) {
await router.push("/")
return
}
await loadStatus()
})
</script>
<template>
<UDashboardNavbar title="Administration: Systemstatus">
<template #right>
<UButton
icon="i-heroicons-arrow-path"
color="neutral"
variant="outline"
:loading="loading"
@click="loadStatus"
>
Aktualisieren
</UButton>
</template>
</UDashboardNavbar>
<UDashboardPanelContent>
<div class="space-y-6">
<UAlert
:icon="overallStatus === 'ok' ? 'i-heroicons-check-circle' : 'i-heroicons-exclamation-triangle'"
:color="overallStatus === 'ok' ? 'success' : 'warning'"
variant="soft"
:title="overallStatus === 'ok' ? 'System läuft' : 'System prüfen'"
:description="status?.checkedAt ? `Letzte Prüfung: ${new Date(status.checkedAt).toLocaleString('de-DE')}` : 'Noch keine Prüfung geladen.'"
/>
<div class="grid gap-4 xl:grid-cols-3">
<UCard :ui="{ root: 'rounded-lg' }">
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-cpu-chip" class="size-5 text-primary" />
<h2 class="text-base font-semibold text-highlighted">Server</h2>
</div>
</template>
<div class="space-y-3 text-sm">
<div class="flex justify-between gap-3">
<span class="text-muted">Host</span>
<span class="truncate text-highlighted">{{ status?.server.hostname || "-" }}</span>
</div>
<div class="flex justify-between gap-3">
<span class="text-muted">CPU</span>
<span class="text-highlighted">{{ status?.server.cpuCount || "-" }} Kerne</span>
</div>
<div class="flex justify-between gap-3">
<span class="text-muted">Load</span>
<span class="text-highlighted">
{{ status?.server.load.one ?? "-" }} · {{ status?.server.load.five ?? "-" }} · {{ status?.server.load.fifteen ?? "-" }}
</span>
</div>
<div class="flex justify-between gap-3">
<span class="text-muted">Uptime</span>
<span class="text-highlighted">{{ formatDuration(status?.server.uptimeSeconds) }}</span>
</div>
</div>
</UCard>
<UCard :ui="{ root: 'rounded-lg' }">
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-circle-stack" class="size-5 text-primary" />
<h2 class="text-base font-semibold text-highlighted">Speicher</h2>
</div>
</template>
<div class="space-y-4 text-sm">
<div>
<div class="mb-1 flex justify-between">
<span class="text-muted">RAM</span>
<span class="text-highlighted">{{ status?.server.memory.usedPercent ?? "-" }}%</span>
</div>
<UProgress :model-value="status?.server.memory.usedPercent || 0" />
<p class="mt-1 text-xs text-muted">
{{ formatBytes(status?.server.memory.usedBytes) }} von {{ formatBytes(status?.server.memory.totalBytes) }}
</p>
</div>
<div>
<div class="mb-1 flex justify-between">
<span class="text-muted">Root-Dateisystem</span>
<span class="text-highlighted">{{ status?.server.disk.rootUsedPercent ?? "-" }}%</span>
</div>
<UProgress :model-value="status?.server.disk.rootUsedPercent || 0" />
<p class="mt-1 text-xs text-muted">
{{ formatBytes(status?.server.disk.rootUsedBytes) }} von {{ formatBytes(status?.server.disk.rootTotalBytes) }}
</p>
</div>
</div>
</UCard>
<UCard :ui="{ root: 'rounded-lg' }">
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-server-stack" class="size-5 text-primary" />
<h2 class="text-base font-semibold text-highlighted">Backend</h2>
</div>
</template>
<div class="space-y-3 text-sm">
<div class="flex justify-between gap-3">
<span class="text-muted">Status</span>
<UBadge color="success" variant="soft">{{ status?.backend.status || "-" }}</UBadge>
</div>
<div class="flex justify-between gap-3">
<span class="text-muted">Laufzeit</span>
<span class="text-highlighted">{{ formatDuration(status?.backend.uptimeSeconds) }}</span>
</div>
<div class="flex justify-between gap-3">
<span class="text-muted">Node.js</span>
<span class="text-highlighted">{{ status?.backend.nodeVersion || "-" }}</span>
</div>
<div class="flex justify-between gap-3">
<span class="text-muted">Umgebung</span>
<span class="text-highlighted">{{ status?.backend.environment || "-" }}</span>
</div>
</div>
</UCard>
</div>
<UCard :ui="{ root: 'rounded-lg' }">
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-signal" class="size-5 text-primary" />
<h2 class="text-base font-semibold text-highlighted">Dienste</h2>
</div>
</template>
<div class="divide-y divide-default">
<div
v-for="service in serviceRows"
:key="service.key"
class="flex items-center justify-between gap-4 py-3"
>
<div class="min-w-0">
<p class="font-medium text-highlighted">{{ service.label }}</p>
<p class="truncate text-xs text-muted">
{{ service.error || service.url || service.publicBaseUrl || service.status }}
</p>
</div>
<UBadge
:color="service.ok ? 'success' : 'error'"
variant="soft"
>
{{ service.ok ? "OK" : "Fehler" }}
</UBadge>
</div>
</div>
</UCard>
</div>
</UDashboardPanelContent>
</template>

File diff suppressed because it is too large Load Diff

1
mobile/.nvmrc Normal file
View File

@@ -0,0 +1 @@
22.22.3

View File

@@ -1,50 +1,37 @@
# Welcome to your Expo app 👋 # FEDEO Mobile
This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app). Expo/React-Native-App für FEDEO. Der iOS-TestFlight-Build wird als Store-Build erstellt und enthält das JavaScript-Bundle. Metro wird dafür nicht benötigt.
## Get started ## Voraussetzungen
1. Install dependencies - Node.js `22.22.3` verwenden, siehe `.nvmrc`.
- EAS CLI installieren oder über `npx eas-cli` ausführen.
- Apple Developer Zugriff für `software.federspiel.fedeo`.
- Produktions-API ist im EAS-Profil `testflight` auf `https://app.fedeo.de/backend` gesetzt.
```bash ## Entwicklung im Simulator
npm install
```
2. Start the app Wenn Port `8081` lokal belegt ist, Metro auf einem freien Port starten und den Simulator auf diesen Port setzen:
```bash
npx expo start
```
In the output, you'll find options to open the app in a
- [development build](https://docs.expo.dev/develop/development-builds/introduction/)
- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
## Get a fresh project
When you're ready, run:
```bash ```bash
npm run reset-project npx --yes -p node@22 node ./node_modules/expo/bin/cli start --dev-client --localhost --port 8082 --clear
xcrun simctl spawn booted defaults write software.federspiel.fedeo RCT_jsLocation '127.0.0.1:8082'
xcrun simctl spawn booted defaults write software.federspiel.fedeo RCT_packager_scheme 'http'
xcrun simctl terminate booted software.federspiel.fedeo
xcrun simctl launch booted software.federspiel.fedeo
``` ```
This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing. ## TestFlight vorbereiten
## Learn more ```bash
npm run preflight:testflight
npm run build:ios:testflight
```
To learn more about developing your project with Expo, look at the following resources: Nach erfolgreichem EAS-Build:
- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides). ```bash
- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web. npm run submit:ios:testflight
```
## Join the community Der TestFlight-Build nutzt das EAS-Profil `testflight` aus `eas.json` mit Store-Distribution und automatischer Buildnummer-Erhöhung.
Join our community of developers creating universal apps.
- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.

View File

@@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "FEDEO", "name": "FEDEO",
"slug": "fedeo-mobile", "slug": "fedeo-mobile",
"version": "1.0.0", "version": "2.0.0",
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"scheme": "fedeo", "scheme": "fedeo",
@@ -10,8 +10,8 @@
"newArchEnabled": true, "newArchEnabled": true,
"ios": { "ios": {
"supportsTablet": true, "supportsTablet": true,
"bundleIdentifier": "de.fedeo.mobile", "bundleIdentifier": "software.federspiel.fedeo",
"buildNumber": "1", "buildNumber": "3",
"infoPlist": { "infoPlist": {
"NSCameraUsageDescription": "Die Kamera wird benötigt, um Fotos zu Projekten und Objekten als Dokumente hochzuladen.", "NSCameraUsageDescription": "Die Kamera wird benötigt, um Fotos zu Projekten und Objekten als Dokumente hochzuladen.",
"NSPhotoLibraryUsageDescription": "Der Zugriff auf Fotos wird benötigt, um Bilder als Dokumente hochzuladen.", "NSPhotoLibraryUsageDescription": "Der Zugriff auf Fotos wird benötigt, um Bilder als Dokumente hochzuladen.",
@@ -21,7 +21,7 @@
} }
}, },
"android": { "android": {
"package": "de.fedeo.mobile", "package": "software.federspiel.fedeo",
"permissions": [ "permissions": [
"android.permission.BLUETOOTH_SCAN", "android.permission.BLUETOOTH_SCAN",
"android.permission.BLUETOOTH_CONNECT", "android.permission.BLUETOOTH_CONNECT",
@@ -54,7 +54,8 @@
} }
} }
], ],
"react-native-ble-plx" "react-native-ble-plx",
"expo-web-browser"
], ],
"experiments": { "experiments": {
"typedRoutes": true, "typedRoutes": true,

View File

@@ -50,6 +50,13 @@ export default function TabLayout() {
tabBarIcon: ({ color }) => <IconSymbol size={24} name="folder.fill" color={color} />, tabBarIcon: ({ color }) => <IconSymbol size={24} name="folder.fill" color={color} />,
}} }}
/> />
<Tabs.Screen
name="communication"
options={{
title: 'Kommunikation',
tabBarIcon: ({ color }) => <IconSymbol size={24} name="message.fill" color={color} />,
}}
/>
<Tabs.Screen <Tabs.Screen
name="time" name="time"
options={{ options={{

View File

@@ -0,0 +1,918 @@
import * as DocumentPicker from 'expo-document-picker';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ActivityIndicator,
Alert,
FlatList,
KeyboardAvoidingView,
Modal,
Platform,
Pressable,
RefreshControl,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import {
createMatrixRoom,
deleteMatrixMessage,
editMatrixMessage,
fetchMatrixIdentity,
fetchMatrixMembers,
fetchMatrixMessages,
fetchMatrixRooms,
fetchMatrixStatus,
fetchMatrixUsers,
inviteMatrixMember,
markMatrixRoomRead,
MatrixMember,
MatrixMessage,
MatrixRoom,
MatrixStatus,
MatrixUser,
provisionMatrixRoom,
provisionMatrixUser,
reactToMatrixMessage,
removeMatrixMember,
sendMatrixMessage,
syncMatrixMembers,
syncMatrixRoom,
uploadMatrixAttachment,
} from '@/src/lib/api';
import { useAuth } from '@/src/providers/auth-provider';
const PRIMARY = '#69c350';
const REACTION_PRESETS = ['👍', '✅', '👀', '🙏'];
type DialogMode = 'create-room' | 'edit-message' | 'invite-member' | null;
function normalizeRoomKey(value: string): string {
return value
.trim()
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9_-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 48);
}
function messageTime(value: MatrixMessage['timestamp']): string {
if (!value) return '';
const date = typeof value === 'number' ? new Date(value) : new Date(value);
if (Number.isNaN(date.getTime())) return '';
return date.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function messagePreview(message: MatrixMessage): string {
return message.body || message.attachment?.fileName || 'Nachricht';
}
function sortMessages(messages: MatrixMessage[]): MatrixMessage[] {
return [...messages].sort((a, b) => {
const left = a.timestamp ? new Date(a.timestamp).getTime() : 0;
const right = b.timestamp ? new Date(b.timestamp).getTime() : 0;
return left - right;
});
}
function mergeMessages(current: MatrixMessage[], incoming: MatrixMessage[]): MatrixMessage[] {
const byId = new Map<string, MatrixMessage>();
for (const message of current) byId.set(message.id, message);
for (const message of incoming || []) byId.set(message.id, { ...byId.get(message.id), ...message });
return sortMessages(Array.from(byId.values()));
}
function applyReplacements(current: MatrixMessage[], replacements: MatrixMessage[] = []): MatrixMessage[] {
if (!replacements.length) return current;
const byTarget = new Map<string, MatrixMessage>();
for (const replacement of replacements) {
const targetId = String(replacement.targetEventId || replacement.replyToEventId || replacement.id || '');
if (targetId) byTarget.set(targetId, replacement);
}
return current.map((message) => {
const replacement = byTarget.get(message.id);
if (!replacement) return message;
return {
...message,
body: replacement.body ?? message.body,
edited: true,
timestamp: replacement.timestamp || message.timestamp,
};
});
}
function applyRedactions(
current: MatrixMessage[],
redactions: { redacts?: string; eventId?: string; targetEventId?: string }[] = []
): MatrixMessage[] {
if (!redactions.length) return current;
const ids = new Set(redactions.map((item) => item.redacts || item.eventId || item.targetEventId).filter(Boolean));
return current.filter((message) => !ids.has(message.id));
}
function applyReactions(
current: MatrixMessage[],
reactions: { targetEventId?: string; key: string; count?: number; own?: boolean; senders?: string[] }[] = []
): MatrixMessage[] {
if (!reactions.length) return current;
return current.map((message) => {
const next = reactions.filter((reaction) => reaction.targetEventId === message.id);
if (!next.length) return message;
return { ...message, reactions: next };
});
}
export default function CommunicationScreen() {
const { token } = useAuth();
const messageListRef = useRef<FlatList<MatrixMessage>>(null);
const [status, setStatus] = useState<MatrixStatus | null>(null);
const [matrixUserId, setMatrixUserId] = useState('');
const [rooms, setRooms] = useState<MatrixRoom[]>([]);
const [members, setMembers] = useState<MatrixMember[]>([]);
const [users, setUsers] = useState<MatrixUser[]>([]);
const [messages, setMessages] = useState<MatrixMessage[]>([]);
const [activeRoomKey, setActiveRoomKey] = useState('allgemein');
const [syncSince, setSyncSince] = useState<string | undefined>();
const [draft, setDraft] = useState('');
const [search, setSearch] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [sending, setSending] = useState(false);
const [provisioning, setProvisioning] = useState(false);
const [membersOpen, setMembersOpen] = useState(false);
const [replyTarget, setReplyTarget] = useState<MatrixMessage | null>(null);
const [editingMessage, setEditingMessage] = useState<MatrixMessage | null>(null);
const [dialogMode, setDialogMode] = useState<DialogMode>(null);
const [roomName, setRoomName] = useState('');
const [roomTopic, setRoomTopic] = useState('');
const [roomKey, setRoomKey] = useState('');
const [dialogText, setDialogText] = useState('');
const [selectedUserId, setSelectedUserId] = useState('');
const activeRoom = useMemo(
() => rooms.find((room) => room.key === activeRoomKey) || rooms[0] || null,
[activeRoomKey, rooms]
);
const visibleRooms = useMemo(() => {
const query = search.trim().toLowerCase();
const base = [...rooms].sort((a, b) => {
const groupCompare = String(a.group || '').localeCompare(String(b.group || ''));
if (groupCompare !== 0) return groupCompare;
return String(a.name || a.key).localeCompare(String(b.name || b.key));
});
if (!query) return base;
return base.filter((room) =>
[room.name, room.key, room.topic, room.email, room.projectNumber]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(query))
);
}, [rooms, search]);
const inviteCandidates = useMemo(() => {
const existing = new Set(members.map((member) => member.matrixUserId));
return users.filter((user) => !existing.has(user.matrixUserId));
}, [members, users]);
const loadRooms = useCallback(async () => {
if (!token) return;
const [nextStatus, identity, nextRooms, nextUsers] = await Promise.all([
fetchMatrixStatus(token),
fetchMatrixIdentity(token),
fetchMatrixRooms(token),
fetchMatrixUsers(token),
]);
setStatus(nextStatus);
setMatrixUserId(identity.matrixUserId || '');
setRooms(nextRooms);
if (!nextRooms.some((room) => room.key === activeRoomKey) && nextRooms[0]) {
setActiveRoomKey(nextRooms[0].key);
}
setUsers(nextUsers);
}, [activeRoomKey, token]);
const loadRoomContent = useCallback(
async (roomKeyToLoad: string, showSpinner = true) => {
if (!token || !roomKeyToLoad) return;
if (showSpinner) setLoading(true);
setError(null);
try {
const [nextMessages, nextMembers, sync] = await Promise.all([
fetchMatrixMessages(token, roomKeyToLoad),
fetchMatrixMembers(token, roomKeyToLoad),
syncMatrixRoom(token, roomKeyToLoad, undefined, true),
]);
const merged = mergeMessages(nextMessages, sync.messages || []);
setMessages(sortMessages(merged));
setMembers(sync.members || nextMembers);
setSyncSince(sync.nextBatch);
const last = merged.at(-1);
if (last?.id) await markMatrixRoomRead(token, roomKeyToLoad, last.id);
requestAnimationFrame(() => messageListRef.current?.scrollToEnd({ animated: true }));
} catch (err) {
setError(err instanceof Error ? err.message : 'Kommunikation konnte nicht geladen werden.');
} finally {
setLoading(false);
setRefreshing(false);
}
},
[token]
);
const refreshAll = useCallback(
async (showSpinner = true) => {
if (!token) return;
if (showSpinner) setLoading(true);
setError(null);
try {
await loadRooms();
await loadRoomContent(activeRoomKey, false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Matrix-Kommunikation konnte nicht geladen werden.');
setLoading(false);
setRefreshing(false);
}
},
[activeRoomKey, loadRoomContent, loadRooms, token]
);
const pollSync = useCallback(async () => {
if (!token || !activeRoom?.exists || !syncSince) return;
try {
const sync = await syncMatrixRoom(token, activeRoom.key, syncSince);
setSyncSince(sync.nextBatch || syncSince);
setMembers((current) => sync.members || current);
setMessages((current) => {
let next = mergeMessages(current, sync.messages || []);
next = applyReplacements(next, sync.replacements);
next = applyReactions(next, sync.reactions);
next = applyRedactions(next, sync.redactions);
return next;
});
} catch {
// Polling errors are surfaced by manual refresh to avoid noisy chat usage.
}
}, [activeRoom, syncSince, token]);
useEffect(() => {
void refreshAll(true);
}, [refreshAll]);
useEffect(() => {
if (!activeRoomKey) return;
setReplyTarget(null);
setEditingMessage(null);
setMessages([]);
setMembers([]);
setSyncSince(undefined);
void loadRoomContent(activeRoomKey, true);
}, [activeRoomKey, loadRoomContent]);
useEffect(() => {
const id = setInterval(() => void pollSync(), 5000);
return () => clearInterval(id);
}, [pollSync]);
async function ensureActiveRoom(): Promise<MatrixRoom | null> {
if (!token || !activeRoom) return null;
if (activeRoom.exists) return activeRoom;
setProvisioning(true);
try {
const room = await provisionMatrixRoom(token, activeRoom);
setRooms((current) => current.map((item) => (item.key === activeRoom.key ? { ...item, ...room, exists: true } : item)));
await loadRoomContent(activeRoom.key, false);
return { ...activeRoom, ...room, exists: true };
} catch (err) {
setError(err instanceof Error ? err.message : 'Raum konnte nicht bereitgestellt werden.');
return null;
} finally {
setProvisioning(false);
}
}
async function sendMessage() {
if (!token || !draft.trim()) return;
const room = await ensureActiveRoom();
if (!room) return;
setSending(true);
try {
const message = editingMessage
? await editMatrixMessage(token, room.key, editingMessage.id, draft.trim())
: await sendMatrixMessage(token, room.key, draft.trim(), replyTarget?.id);
setMessages((current) =>
editingMessage
? current.map((item) => (item.id === editingMessage.id ? { ...item, ...message, edited: true } : item))
: mergeMessages(current, [message])
);
setDraft('');
setReplyTarget(null);
setEditingMessage(null);
requestAnimationFrame(() => messageListRef.current?.scrollToEnd({ animated: true }));
} catch (err) {
setError(err instanceof Error ? err.message : 'Nachricht konnte nicht gesendet werden.');
} finally {
setSending(false);
}
}
async function pickAttachment() {
if (!token) return;
const room = await ensureActiveRoom();
if (!room) return;
const result = await DocumentPicker.getDocumentAsync({ copyToCacheDirectory: true, multiple: false });
if (result.canceled || !result.assets[0]) return;
setSending(true);
try {
const asset = result.assets[0];
const message = await uploadMatrixAttachment(token, room.key, {
uri: asset.uri,
name: asset.name || 'Anhang',
mimeType: asset.mimeType,
});
setMessages((current) => mergeMessages(current, [message]));
requestAnimationFrame(() => messageListRef.current?.scrollToEnd({ animated: true }));
} catch (err) {
setError(err instanceof Error ? err.message : 'Anhang konnte nicht hochgeladen werden.');
} finally {
setSending(false);
}
}
async function provisionUser() {
if (!token) return;
setProvisioning(true);
try {
const identity = await provisionMatrixUser(token);
setMatrixUserId(identity.matrixUserId || '');
await refreshAll(false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Matrix-Benutzer konnte nicht erstellt werden.');
} finally {
setProvisioning(false);
}
}
async function createRoomFromDialog() {
if (!token || !roomName.trim()) return;
const key = roomKey.trim() || normalizeRoomKey(roomName);
if (!key) return;
setProvisioning(true);
try {
const room = await createMatrixRoom(token, {
key,
name: roomName.trim(),
topic: roomTopic.trim() || null,
type: 'room',
});
setRooms((current) => [{ ...room, group: 'Räume', exists: true }, ...current.filter((item) => item.key !== key)]);
setActiveRoomKey(key);
setDialogMode(null);
setRoomName('');
setRoomTopic('');
setRoomKey('');
} catch (err) {
setError(err instanceof Error ? err.message : 'Raum konnte nicht erstellt werden.');
} finally {
setProvisioning(false);
}
}
async function inviteSelectedUser() {
if (!token || !activeRoom || !selectedUserId) return;
const room = await ensureActiveRoom();
if (!room) return;
setProvisioning(true);
try {
await inviteMatrixMember(token, room.key, selectedUserId);
setMembers(await fetchMatrixMembers(token, room.key));
setDialogMode(null);
setSelectedUserId('');
} catch (err) {
setError(err instanceof Error ? err.message : 'Mitglied konnte nicht eingeladen werden.');
} finally {
setProvisioning(false);
}
}
function openEdit(message: MatrixMessage) {
setEditingMessage(message);
setDialogText(message.body || '');
setDialogMode('edit-message');
}
function confirmDelete(message: MatrixMessage) {
Alert.alert('Nachricht löschen', 'Diese Nachricht wirklich entfernen?', [
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Löschen',
style: 'destructive',
onPress: () => {
if (!token || !activeRoom) return;
void deleteMatrixMessage(token, activeRoom.key, message.id)
.then(() => setMessages((current) => current.filter((item) => item.id !== message.id)))
.catch((err) => setError(err instanceof Error ? err.message : 'Nachricht konnte nicht gelöscht werden.'));
},
},
]);
}
function confirmRemoveMember(member: MatrixMember) {
Alert.alert('Mitglied entfernen', `${member.displayName || member.matrixUserId} aus dem Raum entfernen?`, [
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Entfernen',
style: 'destructive',
onPress: () => {
if (!token || !activeRoom) return;
void removeMatrixMember(token, activeRoom.key, member.matrixUserId)
.then(() => setMembers((current) => current.filter((item) => item.matrixUserId !== member.matrixUserId)))
.catch((err) => setError(err instanceof Error ? err.message : 'Mitglied konnte nicht entfernt werden.'));
},
},
]);
}
async function addReaction(message: MatrixMessage, key: string) {
if (!token || !activeRoom) return;
try {
await reactToMatrixMessage(token, activeRoom.key, message.id, key);
await pollSync();
} catch (err) {
setError(err instanceof Error ? err.message : 'Reaktion konnte nicht gesendet werden.');
}
}
async function syncMembersNow() {
if (!token || !activeRoom) return;
setProvisioning(true);
try {
await syncMatrixMembers(token, activeRoom.key);
setMembers(await fetchMatrixMembers(token, activeRoom.key));
} catch (err) {
setError(err instanceof Error ? err.message : 'Mitglieder konnten nicht synchronisiert werden.');
} finally {
setProvisioning(false);
}
}
function renderRoom({ item }: { item: MatrixRoom }) {
const active = item.key === activeRoomKey;
return (
<Pressable style={[styles.roomChip, active && styles.roomChipActive]} onPress={() => setActiveRoomKey(item.key)}>
<Text style={[styles.roomGroup, active && styles.roomTextActive]}>{item.group || 'Raum'}</Text>
<Text style={[styles.roomName, active && styles.roomTextActive]} numberOfLines={1}>
{item.name || item.key}
</Text>
<View style={styles.roomMetaRow}>
{!item.exists ? <Text style={styles.roomBadgeMuted}>bereitstellen</Text> : null}
{item.unread ? <Text style={styles.roomBadge}>{item.mentions ? `@${item.mentions}` : item.unread}</Text> : null}
</View>
</Pressable>
);
}
function renderMessage({ item }: { item: MatrixMessage }) {
const own = Boolean(item.own || item.sender === matrixUserId);
const reply = item.replyToEventId ? messages.find((message) => message.id === item.replyToEventId) : null;
return (
<View style={[styles.messageRow, own && styles.messageRowOwn]}>
<View style={[styles.messageBubble, own && styles.messageBubbleOwn]}>
<View style={styles.messageHeader}>
<Text style={[styles.messageSender, own && styles.messageSenderOwn]} numberOfLines={1}>
{own ? 'Du' : item.senderDisplayName || item.sender}
</Text>
<Text style={[styles.messageTime, own && styles.messageTimeOwn]}>{messageTime(item.timestamp)}</Text>
</View>
{reply ? (
<View style={[styles.replyBox, own && styles.replyBoxOwn]}>
<Text style={[styles.replyText, own && styles.replyTextOwn]} numberOfLines={2}>
{reply.senderDisplayName || reply.sender}: {messagePreview(reply)}
</Text>
</View>
) : null}
{item.body ? <Text style={[styles.messageBody, own && styles.messageBodyOwn]}>{item.body}</Text> : null}
{item.attachment ? (
<View style={[styles.attachmentBox, own && styles.attachmentBoxOwn]}>
<Text style={[styles.attachmentTitle, own && styles.messageBodyOwn]} numberOfLines={1}>
{item.attachment.fileName || 'Anhang'}
</Text>
<Text style={[styles.attachmentMeta, own && styles.messageTimeOwn]}>
{item.attachment.mimeType || 'Datei'} {item.attachment.size ? `· ${Math.round(item.attachment.size / 1024)} KB` : ''}
</Text>
</View>
) : null}
{item.edited ? <Text style={[styles.editedText, own && styles.messageTimeOwn]}>bearbeitet</Text> : null}
{item.reactions?.length ? (
<View style={styles.reactionRow}>
{item.reactions.map((reaction) => (
<Text key={reaction.key} style={[styles.reactionPill, reaction.own && styles.reactionPillOwn]}>
{reaction.key} {reaction.count || ''}
</Text>
))}
</View>
) : null}
<View style={styles.messageActions}>
<Pressable onPress={() => setReplyTarget(item)}>
<Text style={[styles.actionText, own && styles.actionTextOwn]}>Antworten</Text>
</Pressable>
{REACTION_PRESETS.map((reaction) => (
<Pressable key={reaction} onPress={() => addReaction(item, reaction)}>
<Text style={[styles.actionText, own && styles.actionTextOwn]}>{reaction}</Text>
</Pressable>
))}
{own && !item.attachment ? (
<Pressable onPress={() => openEdit(item)}>
<Text style={[styles.actionText, styles.actionTextOwn]}>Bearbeiten</Text>
</Pressable>
) : null}
{own ? (
<Pressable onPress={() => confirmDelete(item)}>
<Text style={[styles.actionText, styles.actionTextDestructive]}>Löschen</Text>
</Pressable>
) : null}
</View>
</View>
</View>
);
}
return (
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.screen}>
<View style={styles.header}>
<View style={styles.headerMain}>
<Text style={styles.title}>Kommunikation</Text>
<Text style={styles.subtitle} numberOfLines={1}>
{activeRoom?.name || 'Matrix Chat'}
</Text>
</View>
<Pressable style={styles.headerButton} onPress={() => setDialogMode('create-room')}>
<Text style={styles.headerButtonText}>+</Text>
</Pressable>
<Pressable style={styles.headerButton} onPress={() => setMembersOpen((value) => !value)}>
<Text style={styles.headerButtonText}></Text>
</Pressable>
</View>
{error ? <Text style={styles.error}>{error}</Text> : null}
{status && status.enabled === false ? (
<View style={styles.notice}>
<Text style={styles.noticeTitle}>Matrix ist nicht aktiv</Text>
<Text style={styles.noticeText}>Die Kommunikation ist serverseitig noch nicht aktiviert.</Text>
</View>
) : null}
{!matrixUserId ? (
<View style={styles.notice}>
<Text style={styles.noticeTitle}>Matrix-Benutzer fehlt</Text>
<Text style={styles.noticeText}>Lege deinen Matrix-Zugang an, um Räume nutzen zu können.</Text>
<Pressable style={styles.primaryButton} onPress={provisionUser} disabled={provisioning}>
<Text style={styles.primaryButtonText}>{provisioning ? 'Wird erstellt...' : 'Benutzer erstellen'}</Text>
</Pressable>
</View>
) : null}
<View style={styles.roomPanel}>
<TextInput
style={styles.searchInput}
placeholder="Räume, Projekte oder Personen suchen"
value={search}
onChangeText={setSearch}
/>
<FlatList
data={visibleRooms}
horizontal
keyExtractor={(item) => item.key}
renderItem={renderRoom}
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.roomList}
/>
</View>
{membersOpen ? (
<View style={styles.membersPanel}>
<View style={styles.membersHeader}>
<Text style={styles.membersTitle}>Mitglieder · {members.length}</Text>
<View style={styles.membersActions}>
<Pressable onPress={syncMembersNow}>
<Text style={styles.linkText}>Sync</Text>
</Pressable>
<Pressable onPress={() => setDialogMode('invite-member')}>
<Text style={styles.linkText}>Einladen</Text>
</Pressable>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.memberList}>
{members.map((member) => (
<Pressable key={member.matrixUserId} style={styles.memberPill} onLongPress={() => confirmRemoveMember(member)}>
<Text style={styles.memberName}>{member.displayName || member.matrixUserId}</Text>
</Pressable>
))}
</ScrollView>
</View>
) : null}
{!activeRoom?.exists ? (
<View style={styles.provisionPanel}>
<Text style={styles.noticeTitle}>Raum noch nicht bereitgestellt</Text>
<Text style={styles.noticeText}>Beim Öffnen wird der Matrix-Raum inklusive Einladungen angelegt.</Text>
<Pressable style={styles.primaryButton} onPress={ensureActiveRoom} disabled={provisioning}>
<Text style={styles.primaryButtonText}>{provisioning ? 'Wird bereitgestellt...' : 'Raum bereitstellen'}</Text>
</Pressable>
</View>
) : null}
{loading ? (
<View style={styles.loading}>
<ActivityIndicator />
<Text style={styles.loadingText}>Nachrichten werden geladen...</Text>
</View>
) : (
<FlatList
ref={messageListRef}
data={messages}
keyExtractor={(item) => item.id}
renderItem={renderMessage}
contentContainerStyle={styles.messageList}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={() => {
setRefreshing(true);
void refreshAll(false);
}}
/>
}
ListEmptyComponent={<Text style={styles.emptyText}>Noch keine Nachrichten in diesem Raum.</Text>}
onContentSizeChange={() => messageListRef.current?.scrollToEnd({ animated: true })}
/>
)}
{(replyTarget || editingMessage) && !dialogMode ? (
<View style={styles.composerContext}>
<View style={styles.composerContextMain}>
<Text style={styles.composerContextLabel}>{editingMessage ? 'Bearbeiten' : 'Antwort auf'}</Text>
<Text style={styles.composerContextText} numberOfLines={1}>
{messagePreview(editingMessage || replyTarget!)}
</Text>
</View>
<Pressable
onPress={() => {
setReplyTarget(null);
setEditingMessage(null);
setDraft('');
}}>
<Text style={styles.linkText}>Abbrechen</Text>
</Pressable>
</View>
) : null}
<View style={styles.composer}>
<Pressable style={styles.attachButton} onPress={pickAttachment} disabled={sending || !activeRoom}>
<Text style={styles.attachButtonText}>+</Text>
</Pressable>
<TextInput
style={styles.composerInput}
placeholder="Nachricht schreiben"
value={draft}
onChangeText={setDraft}
multiline
/>
<Pressable style={[styles.sendButton, (!draft.trim() || sending) && styles.sendButtonDisabled]} onPress={sendMessage}>
<Text style={styles.sendButtonText}>{sending ? '...' : 'Senden'}</Text>
</Pressable>
</View>
<Modal transparent visible={Boolean(dialogMode)} animationType="fade" onRequestClose={() => setDialogMode(null)}>
<View style={styles.modalBackdrop}>
<View style={styles.modalCard}>
{dialogMode === 'create-room' ? (
<>
<Text style={styles.modalTitle}>Raum erstellen</Text>
<TextInput style={styles.modalInput} placeholder="Name" value={roomName} onChangeText={setRoomName} />
<TextInput
style={styles.modalInput}
placeholder="Schlüssel, optional"
value={roomKey}
onChangeText={setRoomKey}
autoCapitalize="none"
/>
<TextInput style={styles.modalInput} placeholder="Thema, optional" value={roomTopic} onChangeText={setRoomTopic} />
<View style={styles.modalActions}>
<Pressable onPress={() => setDialogMode(null)}>
<Text style={styles.linkText}>Abbrechen</Text>
</Pressable>
<Pressable style={styles.primaryButton} onPress={createRoomFromDialog}>
<Text style={styles.primaryButtonText}>Erstellen</Text>
</Pressable>
</View>
</>
) : null}
{dialogMode === 'edit-message' ? (
<>
<Text style={styles.modalTitle}>Nachricht bearbeiten</Text>
<TextInput style={[styles.modalInput, styles.modalTextarea]} value={dialogText} onChangeText={setDialogText} multiline />
<View style={styles.modalActions}>
<Pressable onPress={() => setDialogMode(null)}>
<Text style={styles.linkText}>Abbrechen</Text>
</Pressable>
<Pressable
style={styles.primaryButton}
onPress={() => {
setDraft(dialogText);
setDialogMode(null);
}}>
<Text style={styles.primaryButtonText}>Übernehmen</Text>
</Pressable>
</View>
</>
) : null}
{dialogMode === 'invite-member' ? (
<>
<Text style={styles.modalTitle}>Mitglied einladen</Text>
<ScrollView style={styles.inviteList}>
{inviteCandidates.map((user) => (
<Pressable
key={user.userId}
style={[styles.inviteRow, selectedUserId === user.userId && styles.inviteRowActive]}
onPress={() => setSelectedUserId(user.userId)}>
<Text style={styles.inviteName}>{user.displayName || user.email || user.userId}</Text>
<Text style={styles.inviteMeta}>{user.matrixUserId}</Text>
</Pressable>
))}
{!inviteCandidates.length ? <Text style={styles.emptyText}>Keine weiteren Benutzer verfügbar.</Text> : null}
</ScrollView>
<View style={styles.modalActions}>
<Pressable onPress={() => setDialogMode(null)}>
<Text style={styles.linkText}>Abbrechen</Text>
</Pressable>
<Pressable style={[styles.primaryButton, !selectedUserId && styles.sendButtonDisabled]} onPress={inviteSelectedUser}>
<Text style={styles.primaryButtonText}>Einladen</Text>
</Pressable>
</View>
</>
) : null}
</View>
</View>
</Modal>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, backgroundColor: '#f9fafb' },
header: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingHorizontal: 16,
paddingVertical: 12,
backgroundColor: '#ffffff',
borderBottomColor: '#e5e7eb',
borderBottomWidth: 1,
},
headerMain: { flex: 1, minWidth: 0 },
title: { color: '#111827', fontSize: 20, fontWeight: '800' },
subtitle: { color: '#6b7280', fontSize: 13, marginTop: 2 },
headerButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: '#d1d5db',
backgroundColor: '#ffffff',
},
headerButtonText: { color: '#111827', fontSize: 20, fontWeight: '700' },
error: { margin: 12, color: '#991b1b', backgroundColor: '#fee2e2', borderRadius: 8, padding: 10 },
notice: { margin: 12, padding: 12, borderRadius: 10, backgroundColor: '#fff7ed', borderWidth: 1, borderColor: '#fed7aa', gap: 8 },
noticeTitle: { color: '#111827', fontWeight: '700', fontSize: 15 },
noticeText: { color: '#6b7280', fontSize: 13 },
roomPanel: { backgroundColor: '#ffffff', borderBottomWidth: 1, borderBottomColor: '#e5e7eb', paddingVertical: 10 },
searchInput: {
marginHorizontal: 16,
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 9,
fontSize: 14,
backgroundColor: '#ffffff',
},
roomList: { paddingHorizontal: 12, paddingTop: 10, gap: 8 },
roomChip: { width: 150, padding: 10, borderRadius: 10, borderWidth: 1, borderColor: '#e5e7eb', backgroundColor: '#ffffff', gap: 3 },
roomChipActive: { backgroundColor: '#eff9ea', borderColor: PRIMARY },
roomGroup: { color: '#6b7280', fontSize: 11, textTransform: 'uppercase' },
roomName: { color: '#111827', fontSize: 14, fontWeight: '700' },
roomTextActive: { color: '#2f6f25' },
roomMetaRow: { flexDirection: 'row', gap: 5, minHeight: 18 },
roomBadge: { color: '#ffffff', backgroundColor: PRIMARY, borderRadius: 9, overflow: 'hidden', paddingHorizontal: 6, fontSize: 11 },
roomBadgeMuted: { color: '#6b7280', backgroundColor: '#f3f4f6', borderRadius: 9, overflow: 'hidden', paddingHorizontal: 6, fontSize: 11 },
membersPanel: { backgroundColor: '#ffffff', borderBottomWidth: 1, borderBottomColor: '#e5e7eb', padding: 12, gap: 8 },
membersHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
membersTitle: { color: '#111827', fontWeight: '700' },
membersActions: { flexDirection: 'row', gap: 16 },
memberList: { gap: 8 },
memberPill: { backgroundColor: '#f3f4f6', paddingHorizontal: 10, paddingVertical: 7, borderRadius: 16 },
memberName: { color: '#374151', fontSize: 12 },
provisionPanel: { margin: 12, padding: 12, borderRadius: 10, backgroundColor: '#ffffff', borderWidth: 1, borderColor: '#e5e7eb', gap: 8 },
loading: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 8 },
loadingText: { color: '#6b7280' },
messageList: { padding: 12, gap: 10, flexGrow: 1 },
emptyText: { color: '#6b7280', textAlign: 'center', padding: 18 },
messageRow: { alignItems: 'flex-start' },
messageRowOwn: { alignItems: 'flex-end' },
messageBubble: {
maxWidth: '88%',
minWidth: 120,
backgroundColor: '#ffffff',
borderRadius: 12,
borderWidth: 1,
borderColor: '#e5e7eb',
padding: 10,
gap: 6,
},
messageBubbleOwn: { backgroundColor: '#3f8f32', borderColor: '#3f8f32' },
messageHeader: { flexDirection: 'row', justifyContent: 'space-between', gap: 10 },
messageSender: { flex: 1, color: '#111827', fontSize: 12, fontWeight: '700' },
messageSenderOwn: { color: '#ffffff' },
messageTime: { color: '#6b7280', fontSize: 11 },
messageTimeOwn: { color: '#dff4d9' },
messageBody: { color: '#111827', fontSize: 15, lineHeight: 21 },
messageBodyOwn: { color: '#ffffff' },
replyBox: { backgroundColor: '#f3f4f6', borderRadius: 8, borderLeftWidth: 3, borderLeftColor: PRIMARY, padding: 7 },
replyBoxOwn: { backgroundColor: '#327628', borderLeftColor: '#dff4d9' },
replyText: { color: '#4b5563', fontSize: 12 },
replyTextOwn: { color: '#dff4d9' },
attachmentBox: { borderWidth: 1, borderColor: '#d1d5db', borderRadius: 8, padding: 8, gap: 2 },
attachmentBoxOwn: { borderColor: '#dff4d9' },
attachmentTitle: { color: '#111827', fontWeight: '700' },
attachmentMeta: { color: '#6b7280', fontSize: 12 },
editedText: { color: '#6b7280', fontSize: 11 },
reactionRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 5 },
reactionPill: { backgroundColor: '#f3f4f6', borderRadius: 12, overflow: 'hidden', paddingHorizontal: 7, paddingVertical: 2, fontSize: 12 },
reactionPillOwn: { backgroundColor: '#dff4d9' },
messageActions: { flexDirection: 'row', flexWrap: 'wrap', gap: 12, paddingTop: 2 },
actionText: { color: '#3f8f32', fontSize: 12, fontWeight: '700' },
actionTextOwn: { color: '#ffffff' },
actionTextDestructive: { color: '#fee2e2' },
composerContext: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
paddingHorizontal: 12,
paddingVertical: 8,
backgroundColor: '#ffffff',
borderTopWidth: 1,
borderTopColor: '#e5e7eb',
},
composerContextMain: { flex: 1, minWidth: 0 },
composerContextLabel: { color: '#6b7280', fontSize: 11, textTransform: 'uppercase' },
composerContextText: { color: '#111827', fontSize: 13, fontWeight: '600' },
composer: { flexDirection: 'row', alignItems: 'flex-end', gap: 8, padding: 10, backgroundColor: '#ffffff', borderTopWidth: 1, borderTopColor: '#e5e7eb' },
attachButton: { width: 40, height: 40, borderRadius: 20, alignItems: 'center', justifyContent: 'center', backgroundColor: '#f3f4f6' },
attachButtonText: { color: '#111827', fontSize: 22, fontWeight: '700' },
composerInput: { flex: 1, maxHeight: 110, borderRadius: 18, backgroundColor: '#f3f4f6', paddingHorizontal: 12, paddingVertical: 10, fontSize: 15 },
sendButton: { minWidth: 70, height: 40, borderRadius: 20, alignItems: 'center', justifyContent: 'center', backgroundColor: PRIMARY, paddingHorizontal: 12 },
sendButtonDisabled: { opacity: 0.45 },
sendButtonText: { color: '#ffffff', fontWeight: '800' },
primaryButton: { alignSelf: 'flex-start', backgroundColor: PRIMARY, borderRadius: 9, paddingHorizontal: 12, paddingVertical: 9 },
primaryButtonText: { color: '#ffffff', fontWeight: '800' },
linkText: { color: '#3f8f32', fontWeight: '800' },
modalBackdrop: { flex: 1, backgroundColor: 'rgba(17, 24, 39, 0.45)', alignItems: 'center', justifyContent: 'center', padding: 18 },
modalCard: { width: '100%', maxWidth: 420, borderRadius: 12, backgroundColor: '#ffffff', padding: 16, gap: 12 },
modalTitle: { color: '#111827', fontSize: 18, fontWeight: '800' },
modalInput: { borderWidth: 1, borderColor: '#d1d5db', borderRadius: 9, paddingHorizontal: 12, paddingVertical: 10, fontSize: 15 },
modalTextarea: { minHeight: 110, textAlignVertical: 'top' },
modalActions: { flexDirection: 'row', justifyContent: 'flex-end', alignItems: 'center', gap: 14 },
inviteList: { maxHeight: 280 },
inviteRow: { padding: 10, borderRadius: 9, borderWidth: 1, borderColor: '#e5e7eb', marginBottom: 8 },
inviteRowActive: { borderColor: PRIMARY, backgroundColor: '#eff9ea' },
inviteName: { color: '#111827', fontWeight: '700' },
inviteMeta: { color: '#6b7280', fontSize: 12, marginTop: 2 },
});

View File

@@ -18,7 +18,6 @@ export default function RootLayout() {
title: 'Projekt', title: 'Projekt',
headerBackButtonDisplayMode: 'minimal', headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '', headerBackTitle: '',
headerBackTitleVisible: false,
headerTintColor: '#111827', headerTintColor: '#111827',
}} }}
/> />
@@ -28,7 +27,6 @@ export default function RootLayout() {
title: 'Konto', title: 'Konto',
headerBackButtonDisplayMode: 'minimal', headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '', headerBackTitle: '',
headerBackTitleVisible: false,
headerTintColor: '#111827', headerTintColor: '#111827',
}} }}
/> />
@@ -38,7 +36,6 @@ export default function RootLayout() {
title: 'Einstellungen', title: 'Einstellungen',
headerBackButtonDisplayMode: 'minimal', headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '', headerBackTitle: '',
headerBackTitleVisible: false,
headerTintColor: '#111827', headerTintColor: '#111827',
}} }}
/> />
@@ -48,7 +45,6 @@ export default function RootLayout() {
title: 'Wiki', title: 'Wiki',
headerBackButtonDisplayMode: 'minimal', headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '', headerBackTitle: '',
headerBackTitleVisible: false,
headerTintColor: '#111827', headerTintColor: '#111827',
}} }}
/> />
@@ -58,7 +54,6 @@ export default function RootLayout() {
title: 'Kunden', title: 'Kunden',
headerBackButtonDisplayMode: 'minimal', headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '', headerBackTitle: '',
headerBackTitleVisible: false,
headerTintColor: '#111827', headerTintColor: '#111827',
}} }}
/> />
@@ -68,7 +63,6 @@ export default function RootLayout() {
title: 'Kunde', title: 'Kunde',
headerBackButtonDisplayMode: 'minimal', headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '', headerBackTitle: '',
headerBackTitleVisible: false,
headerTintColor: '#111827', headerTintColor: '#111827',
}} }}
/> />
@@ -78,7 +72,6 @@ export default function RootLayout() {
title: 'Objekte', title: 'Objekte',
headerBackButtonDisplayMode: 'minimal', headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '', headerBackTitle: '',
headerBackTitleVisible: false,
headerTintColor: '#111827', headerTintColor: '#111827',
}} }}
/> />
@@ -88,7 +81,6 @@ export default function RootLayout() {
title: 'Objekt', title: 'Objekt',
headerBackButtonDisplayMode: 'minimal', headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '', headerBackTitle: '',
headerBackTitleVisible: false,
headerTintColor: '#111827', headerTintColor: '#111827',
}} }}
/> />
@@ -98,7 +90,6 @@ export default function RootLayout() {
title: 'Kundeninventar', title: 'Kundeninventar',
headerBackButtonDisplayMode: 'minimal', headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '', headerBackTitle: '',
headerBackTitleVisible: false,
headerTintColor: '#111827', headerTintColor: '#111827',
}} }}
/> />
@@ -108,7 +99,6 @@ export default function RootLayout() {
title: 'Nimbot M2', title: 'Nimbot M2',
headerBackButtonDisplayMode: 'minimal', headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '', headerBackTitle: '',
headerBackTitleVisible: false,
headerTintColor: '#111827', headerTintColor: '#111827',
}} }}
/> />

View File

@@ -16,6 +16,11 @@ type IconSymbolName = keyof typeof MAPPING;
const MAPPING = { const MAPPING = {
'house.fill': 'home', 'house.fill': 'home',
'paperplane.fill': 'send', 'paperplane.fill': 'send',
'message.fill': 'chat',
'folder.fill': 'folder',
checklist: 'checklist',
'clock.fill': 'schedule',
'ellipsis.circle.fill': 'more-horiz',
'chevron.left.forwardslash.chevron.right': 'code', 'chevron.left.forwardslash.chevron.right': 'code',
'chevron.right': 'chevron-right', 'chevron.right': 'chevron-right',
} as IconMapping; } as IconMapping;

View File

@@ -4,6 +4,7 @@
}, },
"build": { "build": {
"development": { "development": {
"node": "22.22.3",
"developmentClient": true, "developmentClient": true,
"distribution": "internal", "distribution": "internal",
"ios": { "ios": {
@@ -11,13 +12,35 @@
} }
}, },
"preview": { "preview": {
"node": "22.22.3",
"distribution": "internal" "distribution": "internal"
}, },
"testflight": {
"node": "22.22.3",
"distribution": "store",
"autoIncrement": true,
"env": {
"EXPO_PUBLIC_API_BASE": "https://app.fedeo.de/backend"
},
"ios": {
"simulator": false
}
},
"production": { "production": {
"node": "22.22.3",
"distribution": "store",
"env": {
"EXPO_PUBLIC_API_BASE": "https://app.fedeo.de/backend"
},
"autoIncrement": true "autoIncrement": true
} }
}, },
"submit": { "submit": {
"production": {} "testflight": {
"ios": {}
},
"production": {
"ios": {}
}
} }
} }

397
mobile/package-lock.json generated
View File

@@ -1,33 +1,33 @@
{ {
"name": "mobile", "name": "mobile",
"version": "1.0.0", "version": "2.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mobile", "name": "mobile",
"version": "1.0.0", "version": "2.0.0",
"dependencies": { "dependencies": {
"@expo/vector-icons": "^15.0.3", "@expo/vector-icons": "^15.0.3",
"@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",
"expo": "~54.0.33", "expo": "~54.0.34",
"expo-camera": "~17.0.10", "expo-camera": "~17.0.10",
"expo-constants": "~18.0.13", "expo-constants": "~18.0.13",
"expo-document-picker": "^14.0.8", "expo-document-picker": "^14.0.8",
"expo-font": "~14.0.11", "expo-font": "~14.0.11",
"expo-haptics": "~15.0.8", "expo-haptics": "~15.0.8",
"expo-image": "~3.0.11", "expo-image": "~3.0.11",
"expo-image-picker": "~17.0.8", "expo-image-picker": "~17.0.11",
"expo-linking": "~8.0.11", "expo-linking": "~8.0.12",
"expo-router": "~6.0.23", "expo-router": "~6.0.23",
"expo-secure-store": "^15.0.8", "expo-secure-store": "^15.0.8",
"expo-splash-screen": "~31.0.13", "expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.9", "expo-status-bar": "~3.0.9",
"expo-symbols": "~1.0.8", "expo-symbols": "~1.0.8",
"expo-system-ui": "~6.0.9", "expo-system-ui": "~6.0.9",
"expo-web-browser": "~15.0.10", "expo-web-browser": "~15.0.11",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-native": "0.81.5", "react-native": "0.81.5",
@@ -37,7 +37,7 @@
"react-native-safe-area-context": "~5.6.0", "react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0", "react-native-screens": "~4.16.0",
"react-native-web": "~0.21.0", "react-native-web": "~0.21.0",
"react-native-webview": "^13.16.0", "react-native-webview": "13.15.0",
"react-native-worklets": "0.5.1" "react-native-worklets": "0.5.1"
}, },
"devDependencies": { "devDependencies": {
@@ -45,6 +45,9 @@
"eslint": "^9.25.0", "eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0", "eslint-config-expo": "~10.0.0",
"typescript": "~5.9.2" "typescript": "~5.9.2"
},
"engines": {
"node": "22.x"
} }
}, },
"node_modules/@0no-co/graphql.web": { "node_modules/@0no-co/graphql.web": {
@@ -1873,9 +1876,9 @@
} }
}, },
"node_modules/@expo/fingerprint": { "node_modules/@expo/fingerprint": {
"version": "0.15.4", "version": "0.15.5",
"resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.15.4.tgz", "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.15.5.tgz",
"integrity": "sha512-eYlxcrGdR2/j2M6pEDXo9zU9KXXF1vhP+V+Tl+lyY+bU8lnzrN6c637mz6Ye3em2ANy8hhUR03Raf8VsT9Ogng==", "integrity": "sha512-mdVoAMcux1WlM6kd1RoWiHRNqKqS+J6mKmWQ/BKgeh937S/fcW58EE68O6nc4KDXtWi3PBeNHskOFcgyIuD4hw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@expo/spawn-async": "^1.7.2", "@expo/spawn-async": "^1.7.2",
@@ -1885,7 +1888,7 @@
"getenv": "^2.0.0", "getenv": "^2.0.0",
"glob": "^13.0.0", "glob": "^13.0.0",
"ignore": "^5.3.1", "ignore": "^5.3.1",
"minimatch": "^9.0.0", "minimatch": "^10.2.2",
"p-limit": "^3.1.0", "p-limit": "^3.1.0",
"resolve-from": "^5.0.0", "resolve-from": "^5.0.0",
"semver": "^7.6.0" "semver": "^7.6.0"
@@ -1894,34 +1897,46 @@
"fingerprint": "bin/cli.js" "fingerprint": "bin/cli.js"
} }
}, },
"node_modules/@expo/fingerprint/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@expo/fingerprint/node_modules/brace-expansion": { "node_modules/@expo/fingerprint/node_modules/brace-expansion": {
"version": "2.0.2", "version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0" "balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
} }
}, },
"node_modules/@expo/fingerprint/node_modules/minimatch": { "node_modules/@expo/fingerprint/node_modules/minimatch": {
"version": "9.0.5", "version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"license": "ISC", "license": "BlueOak-1.0.0",
"dependencies": { "dependencies": {
"brace-expansion": "^2.0.1" "brace-expansion": "^5.0.5"
}, },
"engines": { "engines": {
"node": ">=16 || 14 >=14.17" "node": "18 || 20 || >=22"
}, },
"funding": { "funding": {
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/@expo/fingerprint/node_modules/semver": { "node_modules/@expo/fingerprint/node_modules/semver": {
"version": "7.7.4", "version": "7.8.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
"license": "ISC", "license": "ISC",
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
@@ -1961,24 +1976,15 @@
} }
}, },
"node_modules/@expo/json-file": { "node_modules/@expo/json-file": {
"version": "10.0.8", "version": "10.0.14",
"resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.8.tgz", "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.14.tgz",
"integrity": "sha512-9LOTh1PgKizD1VXfGQ88LtDH0lRwq9lsTb4aichWTWSWqy3Ugfkhfm3BhzBIkJJfQQ5iJu3m/BoRlEIjoCGcnQ==", "integrity": "sha512-yWwBFywFv+SxkJp/pIzzA416JVYflNUh7pqQzgaA6nXDqRyK7KfrqVzk8PdUfDnqbBcaZZxpzNssfQZzp5KHrA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/code-frame": "~7.10.4", "@babel/code-frame": "^7.20.0",
"json5": "^2.2.3" "json5": "^2.2.3"
} }
}, },
"node_modules/@expo/json-file/node_modules/@babel/code-frame": {
"version": "7.10.4",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz",
"integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==",
"license": "MIT",
"dependencies": {
"@babel/highlight": "^7.10.4"
}
},
"node_modules/@expo/metro": { "node_modules/@expo/metro": {
"version": "54.2.0", "version": "54.2.0",
"resolved": "https://registry.npmjs.org/@expo/metro/-/metro-54.2.0.tgz", "resolved": "https://registry.npmjs.org/@expo/metro/-/metro-54.2.0.tgz",
@@ -2002,9 +2008,9 @@
} }
}, },
"node_modules/@expo/metro-config": { "node_modules/@expo/metro-config": {
"version": "54.0.14", "version": "54.0.15",
"resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-54.0.14.tgz", "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-54.0.15.tgz",
"integrity": "sha512-hxpLyDfOR4L23tJ9W1IbJJsG7k4lv2sotohBm/kTYyiG+pe1SYCAWsRmgk+H42o/wWf/HQjE5k45S5TomGLxNA==", "integrity": "sha512-SqIya4VZ9KHM1S9g+xR0A+QKw1Tfs7Gacx6bQNJ98vs4+O7I5+QP5mHZIB0QSZLUV8opiXebHYTiTu+0OAsIUw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.20.0", "@babel/code-frame": "^7.20.0",
@@ -2025,7 +2031,7 @@
"hermes-parser": "^0.29.1", "hermes-parser": "^0.29.1",
"jsc-safe-url": "^0.2.4", "jsc-safe-url": "^0.2.4",
"lightningcss": "^1.30.1", "lightningcss": "^1.30.1",
"minimatch": "^9.0.0", "picomatch": "^4.0.3",
"postcss": "~8.4.32", "postcss": "~8.4.32",
"resolve-from": "^5.0.0" "resolve-from": "^5.0.0"
}, },
@@ -2038,28 +2044,16 @@
} }
} }
}, },
"node_modules/@expo/metro-config/node_modules/brace-expansion": { "node_modules/@expo/metro-config/node_modules/picomatch": {
"version": "2.0.2", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT", "license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/@expo/metro-config/node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": { "engines": {
"node": ">=16 || 14 >=14.17" "node": ">=12"
}, },
"funding": { "funding": {
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/@expo/metro-runtime": { "node_modules/@expo/metro-runtime": {
@@ -2086,25 +2080,24 @@
} }
}, },
"node_modules/@expo/osascript": { "node_modules/@expo/osascript": {
"version": "2.3.8", "version": "2.4.3",
"resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.3.8.tgz", "resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.4.3.tgz",
"integrity": "sha512-/TuOZvSG7Nn0I8c+FcEaoHeBO07yu6vwDgk7rZVvAXoeAK5rkA09jRyjYsZo+0tMEFaToBeywA6pj50Mb3ny9w==", "integrity": "sha512-wbuj3EebM7W9hN/Wp4xTzKd6rQ2zKJzAxkFxkOOwyysLp0HOAgQ4/5RINyoS241pZUX2rUHq7mAJ7pcCQ8U0Ow==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@expo/spawn-async": "^1.7.2", "@expo/spawn-async": "^1.7.2"
"exec-async": "^2.2.0"
}, },
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/@expo/package-manager": { "node_modules/@expo/package-manager": {
"version": "1.9.10", "version": "1.10.5",
"resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.9.10.tgz", "resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.10.5.tgz",
"integrity": "sha512-axJm+NOj3jVxep49va/+L3KkF3YW/dkV+RwzqUJedZrv4LeTqOG4rhrCaCPXHTvLqCTDKu6j0Xyd28N7mnxsGA==", "integrity": "sha512-nCP9Mebfl3jvOr0/P6VAuyah6PAtun+aihIL2zAtuE8uSe94JWkVZ7051i0MUVO+y3gFpBqnr8IIH5ch+VJjHA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@expo/json-file": "^10.0.8", "@expo/json-file": "^10.0.14",
"@expo/spawn-async": "^1.7.2", "@expo/spawn-async": "^1.7.2",
"chalk": "^4.0.0", "chalk": "^4.0.0",
"npm-package-arg": "^11.0.0", "npm-package-arg": "^11.0.0",
@@ -2204,9 +2197,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@expo/xcpretty": { "node_modules/@expo/xcpretty": {
"version": "4.4.0", "version": "4.4.4",
"resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.0.tgz", "resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.4.tgz",
"integrity": "sha512-o2qDlTqJ606h4xR36H2zWTywmZ3v3842K6TU8Ik2n1mfW0S580VHlt3eItVYdLYz+klaPp7CXqanja8eASZjRw==", "integrity": "sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.20.0", "@babel/code-frame": "^7.20.0",
@@ -5998,36 +5991,30 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/exec-async": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/exec-async/-/exec-async-2.2.0.tgz",
"integrity": "sha512-87OpwcEiMia/DeiKFzaQNBNFeN3XkkpYIh9FyOqq5mS2oKv3CBE67PXoEKcr6nodWdXNogTiQ0jE2NGuoffXPw==",
"license": "MIT"
},
"node_modules/expo": { "node_modules/expo": {
"version": "54.0.33", "version": "54.0.34",
"resolved": "https://registry.npmjs.org/expo/-/expo-54.0.33.tgz", "resolved": "https://registry.npmjs.org/expo/-/expo-54.0.34.tgz",
"integrity": "sha512-3yOEfAKqo+gqHcV8vKcnq0uA5zxlohnhA3fu4G43likN8ct5ZZ3LjAh9wDdKteEkoad3tFPvwxmXW711S5OHUw==", "integrity": "sha512-XkVHguZZDC8BcTQxHAd14/TQFbDp1Wt0Z/KApO9t68Ll5A127hLCPzU+a9gytfCIiyL/V1IpF1vIcOLKEVAoNQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.20.0", "@babel/runtime": "^7.20.0",
"@expo/cli": "54.0.23", "@expo/cli": "54.0.24",
"@expo/config": "~12.0.13", "@expo/config": "~12.0.13",
"@expo/config-plugins": "~54.0.4", "@expo/config-plugins": "~54.0.4",
"@expo/devtools": "0.1.8", "@expo/devtools": "0.1.8",
"@expo/fingerprint": "0.15.4", "@expo/fingerprint": "0.15.5",
"@expo/metro": "~54.2.0", "@expo/metro": "~54.2.0",
"@expo/metro-config": "54.0.14", "@expo/metro-config": "54.0.15",
"@expo/vector-icons": "^15.0.3", "@expo/vector-icons": "^15.0.3",
"@ungap/structured-clone": "^1.3.0", "@ungap/structured-clone": "^1.3.0",
"babel-preset-expo": "~54.0.10", "babel-preset-expo": "~54.0.10",
"expo-asset": "~12.0.12", "expo-asset": "~12.0.13",
"expo-constants": "~18.0.13", "expo-constants": "~18.0.13",
"expo-file-system": "~19.0.21", "expo-file-system": "~19.0.22",
"expo-font": "~14.0.11", "expo-font": "~14.0.11",
"expo-keep-awake": "~15.0.8", "expo-keep-awake": "~15.0.8",
"expo-modules-autolinking": "3.0.24", "expo-modules-autolinking": "3.0.25",
"expo-modules-core": "3.0.29", "expo-modules-core": "3.0.30",
"pretty-format": "^29.7.0", "pretty-format": "^29.7.0",
"react-refresh": "^0.14.2", "react-refresh": "^0.14.2",
"whatwg-url-without-unicode": "8.0.0-3" "whatwg-url-without-unicode": "8.0.0-3"
@@ -6057,13 +6044,13 @@
} }
}, },
"node_modules/expo-asset": { "node_modules/expo-asset": {
"version": "12.0.12", "version": "12.0.13",
"resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.12.tgz", "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.13.tgz",
"integrity": "sha512-CsXFCQbx2fElSMn0lyTdRIyKlSXOal6ilLJd+yeZ6xaC7I9AICQgscY5nj0QcwgA+KYYCCEQEBndMsmj7drOWQ==", "integrity": "sha512-x/p7WvQUnkn6K43b9eL6SPeq5Vnf1E8BDe9bDrWrvMqzyUvJnUFvl+ctg3034s/+UHe7Ne2pAmc0+yzbl8CrDQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@expo/image-utils": "^0.8.8", "@expo/image-utils": "^0.8.8",
"expo-constants": "~18.0.12" "expo-constants": "~18.0.13"
}, },
"peerDependencies": { "peerDependencies": {
"expo": "*", "expo": "*",
@@ -6115,9 +6102,9 @@
} }
}, },
"node_modules/expo-file-system": { "node_modules/expo-file-system": {
"version": "19.0.21", "version": "19.0.22",
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz", "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.22.tgz",
"integrity": "sha512-s3DlrDdiscBHtab/6W1osrjGL+C2bvoInPJD7sOwmxfJ5Woynv2oc+Fz1/xVXaE/V7HE/+xrHC/H45tu6lZzzg==", "integrity": "sha512-l9pgahSc7sJD0bP9vBNeXvZjy8QKDpVHVxWmei/ESQOrzmoj5BidziqLVsyZdxsi+PfdbTtttLTAmddH/JafYA==",
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"expo": "*", "expo": "*",
@@ -6174,9 +6161,9 @@
} }
}, },
"node_modules/expo-image-picker": { "node_modules/expo-image-picker": {
"version": "17.0.10", "version": "17.0.11",
"resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-17.0.10.tgz", "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-17.0.11.tgz",
"integrity": "sha512-a2xrowp2trmvXyUWgX3O6Q2rZaa2C59AqivKI7+bm+wLvMfTEbZgldLX4rEJJhM8xtmEDTNU+lzjtObwzBRGaw==", "integrity": "sha512-/apkoyukDvsCHHb9fzP+F34A1uQqSzUtYH/2P/xJACNEwq+mwEXjXvVU8bzlJq6ih0Qo1+tpVivIa7B9kYSwOQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"expo-image-loader": "~6.0.0" "expo-image-loader": "~6.0.0"
@@ -6196,12 +6183,12 @@
} }
}, },
"node_modules/expo-linking": { "node_modules/expo-linking": {
"version": "8.0.11", "version": "8.0.12",
"resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.11.tgz", "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.12.tgz",
"integrity": "sha512-+VSaNL5om3kOp/SSKO5qe6cFgfSIWnnQDSbA7XLs3ECkYzXRquk5unxNS3pg7eK5kNUmQ4kgLI7MhTggAEUBLA==", "integrity": "sha512-FpXeIpFgZuxihwT9lBo86YD3y6LphBuAhN680MMxm/Y7fmsc57vimn2d3vFu68VI0+Z9w457t494mu2wvlgWTQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"expo-constants": "~18.0.12", "expo-constants": "~18.0.13",
"invariant": "^2.2.4" "invariant": "^2.2.4"
}, },
"peerDependencies": { "peerDependencies": {
@@ -6210,9 +6197,9 @@
} }
}, },
"node_modules/expo-modules-autolinking": { "node_modules/expo-modules-autolinking": {
"version": "3.0.24", "version": "3.0.25",
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.24.tgz", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.25.tgz",
"integrity": "sha512-TP+6HTwhL7orDvsz2VzauyQlXJcAWyU3ANsZ7JGL4DQu8XaZv/A41ZchbtAYLfozNA2Ya1Hzmhx65hXryBMjaQ==", "integrity": "sha512-YmHWctJlwvOuLZccg3cOXvSiXVJrPMKl7g2YR0YHWoGL9v2RvcmgaPJWPSLVW+voNEgEPsbo5UmUrAqbnYcBeg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@expo/spawn-async": "^1.7.2", "@expo/spawn-async": "^1.7.2",
@@ -6226,9 +6213,9 @@
} }
}, },
"node_modules/expo-modules-core": { "node_modules/expo-modules-core": {
"version": "3.0.29", "version": "3.0.30",
"resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-3.0.29.tgz", "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-3.0.30.tgz",
"integrity": "sha512-LzipcjGqk8gvkrOUf7O2mejNWugPkf3lmd9GkqL9WuNyeN2fRwU0Dn77e3ZUKI3k6sI+DNwjkq4Nu9fNN9WS7Q==", "integrity": "sha512-a6IrpAn/Jbmwxi9L+hMmXKpNqnkUpoF7WHOpn02rVLyax2J0gB1vvCVE5rNydplEnt41Q6WxQwvcOjZaIkcSUg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"invariant": "^2.2.4" "invariant": "^2.2.4"
@@ -6501,9 +6488,9 @@
} }
}, },
"node_modules/expo-server": { "node_modules/expo-server": {
"version": "1.0.5", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.5.tgz", "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.6.tgz",
"integrity": "sha512-IGR++flYH70rhLyeXF0Phle56/k4cee87WeQ4mamS+MkVAVP+dDlOHf2nN06Z9Y2KhU0Gp1k+y61KkghF7HdhA==", "integrity": "sha512-vb5TBtskvEdzYuW79lATXutOEBfW5m6U4EFpNjCVZTnI7S//SAsLQkYEpn+EDfn84m6VQfzSGkIVR6YPaScKFA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=20.16.0" "node": ">=20.16.0"
@@ -6568,9 +6555,9 @@
} }
}, },
"node_modules/expo-web-browser": { "node_modules/expo-web-browser": {
"version": "15.0.10", "version": "15.0.11",
"resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.10.tgz", "resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.11.tgz",
"integrity": "sha512-fvDhW4bhmXAeWFNFiInmsGCK83PAqAcQaFyp/3pE/jbdKmFKoRCWr46uZGIfN4msLK/OODhaQ/+US7GSJNDHJg==", "integrity": "sha512-r2LS4Ro6DgUPZkcaEfgt8mp9eJuoA93x11Jh7S6utFe0FEzvUNn2yFhxg8XVwESaaHGt2k5V8LuK36rsp0BeIw==",
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"expo": "*", "expo": "*",
@@ -6578,9 +6565,9 @@
} }
}, },
"node_modules/expo/node_modules/@expo/cli": { "node_modules/expo/node_modules/@expo/cli": {
"version": "54.0.23", "version": "54.0.24",
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.23.tgz", "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.24.tgz",
"integrity": "sha512-km0h72SFfQCmVycH/JtPFTVy69w6Lx1cHNDmfLfQqgKFYeeHTjx7LVDP4POHCtNxFP2UeRazrygJhlh4zz498g==", "integrity": "sha512-5xse1bEgnVUBhOrtttc6xTNJVvjyTRavpzuF0/0nuj+312vfSbk7EiRbG+xJ2pW/iZxnhLPJkFCrPYG0nmheAQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@0no-co/graphql.web": "^1.0.8", "@0no-co/graphql.web": "^1.0.8",
@@ -6592,7 +6579,7 @@
"@expo/image-utils": "^0.8.8", "@expo/image-utils": "^0.8.8",
"@expo/json-file": "^10.0.8", "@expo/json-file": "^10.0.8",
"@expo/metro": "~54.2.0", "@expo/metro": "~54.2.0",
"@expo/metro-config": "~54.0.14", "@expo/metro-config": "~54.0.15",
"@expo/osascript": "^2.3.8", "@expo/osascript": "^2.3.8",
"@expo/package-manager": "^1.9.10", "@expo/package-manager": "^1.9.10",
"@expo/plist": "^0.4.8", "@expo/plist": "^0.4.8",
@@ -6615,16 +6602,16 @@
"connect": "^3.7.0", "connect": "^3.7.0",
"debug": "^4.3.4", "debug": "^4.3.4",
"env-editor": "^0.4.1", "env-editor": "^0.4.1",
"expo-server": "^1.0.5", "expo-server": "^1.0.6",
"freeport-async": "^2.0.0", "freeport-async": "^2.0.0",
"getenv": "^2.0.0", "getenv": "^2.0.0",
"glob": "^13.0.0", "glob": "^13.0.0",
"lan-network": "^0.1.6", "lan-network": "^0.2.1",
"minimatch": "^9.0.0", "minimatch": "^9.0.0",
"node-forge": "^1.3.3", "node-forge": "^1.3.3",
"npm-package-arg": "^11.0.0", "npm-package-arg": "^11.0.0",
"ora": "^3.4.0", "ora": "^3.4.0",
"picomatch": "^3.0.1", "picomatch": "^4.0.3",
"pretty-bytes": "^5.6.0", "pretty-bytes": "^5.6.0",
"pretty-format": "^29.7.0", "pretty-format": "^29.7.0",
"progress": "^2.0.3", "progress": "^2.0.3",
@@ -6665,9 +6652,9 @@
} }
}, },
"node_modules/expo/node_modules/brace-expansion": { "node_modules/expo/node_modules/brace-expansion": {
"version": "2.0.2", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0" "balanced-match": "^1.0.0"
@@ -6689,12 +6676,12 @@
} }
}, },
"node_modules/expo/node_modules/minimatch": { "node_modules/expo/node_modules/minimatch": {
"version": "9.0.5", "version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"brace-expansion": "^2.0.1" "brace-expansion": "^2.0.2"
}, },
"engines": { "engines": {
"node": ">=16 || 14 >=14.17" "node": ">=16 || 14 >=14.17"
@@ -6704,21 +6691,21 @@
} }
}, },
"node_modules/expo/node_modules/picomatch": { "node_modules/expo/node_modules/picomatch": {
"version": "3.0.1", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-3.0.1.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=10" "node": ">=12"
}, },
"funding": { "funding": {
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/expo/node_modules/semver": { "node_modules/expo/node_modules/semver": {
"version": "7.7.4", "version": "7.8.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
"license": "ISC", "license": "ISC",
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
@@ -6728,9 +6715,9 @@
} }
}, },
"node_modules/expo/node_modules/ws": { "node_modules/expo/node_modules/ws": {
"version": "8.19.0", "version": "8.20.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=10.0.0" "node": ">=10.0.0"
@@ -8394,9 +8381,9 @@
} }
}, },
"node_modules/lan-network": { "node_modules/lan-network": {
"version": "0.1.7", "version": "0.2.1",
"resolved": "https://registry.npmjs.org/lan-network/-/lan-network-0.1.7.tgz", "resolved": "https://registry.npmjs.org/lan-network/-/lan-network-0.2.1.tgz",
"integrity": "sha512-mnIlAEMu4OyEvUNdzco9xpuB9YVcPkQec+QsgycBCtPZvEqWPCDPfbAE4OJMdBBWpZWtpCn1xw9jJYlwjWI5zQ==", "integrity": "sha512-ONPnazC96VKDntab9j9JKwIWhZ4ZUceB4A9Epu4Ssg0hYFmtHZSeQ+n15nIwTFmcBUKtExOer8WTJ4GF9MO64A==",
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"lan-network": "dist/lan-network-cli.js" "lan-network": "dist/lan-network-cli.js"
@@ -8451,9 +8438,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/lightningcss": { "node_modules/lightningcss": {
"version": "1.31.1", "version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
"integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"license": "MPL-2.0", "license": "MPL-2.0",
"dependencies": { "dependencies": {
"detect-libc": "^2.0.3" "detect-libc": "^2.0.3"
@@ -8466,23 +8453,23 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
}, },
"optionalDependencies": { "optionalDependencies": {
"lightningcss-android-arm64": "1.31.1", "lightningcss-android-arm64": "1.32.0",
"lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.32.0",
"lightningcss-darwin-x64": "1.31.1", "lightningcss-darwin-x64": "1.32.0",
"lightningcss-freebsd-x64": "1.31.1", "lightningcss-freebsd-x64": "1.32.0",
"lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.32.0",
"lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-gnu": "1.32.0",
"lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-arm64-musl": "1.32.0",
"lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-gnu": "1.32.0",
"lightningcss-linux-x64-musl": "1.31.1", "lightningcss-linux-x64-musl": "1.32.0",
"lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-arm64-msvc": "1.32.0",
"lightningcss-win32-x64-msvc": "1.31.1" "lightningcss-win32-x64-msvc": "1.32.0"
} }
}, },
"node_modules/lightningcss-android-arm64": { "node_modules/lightningcss-android-arm64": {
"version": "1.31.1", "version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
"integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -8500,9 +8487,9 @@
} }
}, },
"node_modules/lightningcss-darwin-arm64": { "node_modules/lightningcss-darwin-arm64": {
"version": "1.31.1", "version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
"integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -8520,9 +8507,9 @@
} }
}, },
"node_modules/lightningcss-darwin-x64": { "node_modules/lightningcss-darwin-x64": {
"version": "1.31.1", "version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
"integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -8540,9 +8527,9 @@
} }
}, },
"node_modules/lightningcss-freebsd-x64": { "node_modules/lightningcss-freebsd-x64": {
"version": "1.31.1", "version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
"integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -8560,9 +8547,9 @@
} }
}, },
"node_modules/lightningcss-linux-arm-gnueabihf": { "node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.31.1", "version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
"integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -8580,9 +8567,9 @@
} }
}, },
"node_modules/lightningcss-linux-arm64-gnu": { "node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.31.1", "version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
"integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -8600,9 +8587,9 @@
} }
}, },
"node_modules/lightningcss-linux-arm64-musl": { "node_modules/lightningcss-linux-arm64-musl": {
"version": "1.31.1", "version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
"integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -8620,9 +8607,9 @@
} }
}, },
"node_modules/lightningcss-linux-x64-gnu": { "node_modules/lightningcss-linux-x64-gnu": {
"version": "1.31.1", "version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
"integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -8640,9 +8627,9 @@
} }
}, },
"node_modules/lightningcss-linux-x64-musl": { "node_modules/lightningcss-linux-x64-musl": {
"version": "1.31.1", "version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
"integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -8660,9 +8647,9 @@
} }
}, },
"node_modules/lightningcss-win32-arm64-msvc": { "node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.31.1", "version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
"integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -8680,9 +8667,9 @@
} }
}, },
"node_modules/lightningcss-win32-x64-msvc": { "node_modules/lightningcss-win32-x64-msvc": {
"version": "1.31.1", "version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
"integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -9402,9 +9389,9 @@
} }
}, },
"node_modules/node-forge": { "node_modules/node-forge": {
"version": "1.3.3", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz",
"integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==", "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==",
"license": "(BSD-3-Clause OR GPL-2.0)", "license": "(BSD-3-Clause OR GPL-2.0)",
"engines": { "engines": {
"node": ">= 6.13.0" "node": ">= 6.13.0"
@@ -9447,9 +9434,9 @@
} }
}, },
"node_modules/npm-package-arg/node_modules/semver": { "node_modules/npm-package-arg/node_modules/semver": {
"version": "7.7.4", "version": "7.8.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
"license": "ISC", "license": "ISC",
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
@@ -10453,9 +10440,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-native-webview": { "node_modules/react-native-webview": {
"version": "13.16.0", "version": "13.15.0",
"resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.16.0.tgz", "resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.15.0.tgz",
"integrity": "sha512-Nh13xKZWW35C0dbOskD7OX01nQQavOzHbCw9XoZmar4eXCo7AvrYJ0jlUfRVVIJzqINxHlpECYLdmAdFsl9xDA==", "integrity": "sha512-Vzjgy8mmxa/JO6l5KZrsTC7YemSdq+qB01diA0FqjUTaWGAGwuykpJ73MDj3+mzBSlaDxAEugHzTtkUQkQEQeQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"escape-string-regexp": "^4.0.0", "escape-string-regexp": "^4.0.0",
@@ -11722,9 +11709,9 @@
} }
}, },
"node_modules/tar": { "node_modules/tar": {
"version": "7.5.9", "version": "7.5.15",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.9.tgz", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz",
"integrity": "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==", "integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==",
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"dependencies": { "dependencies": {
"@isaacs/fs-minipass": "^4.0.0", "@isaacs/fs-minipass": "^4.0.0",
@@ -12155,9 +12142,9 @@
} }
}, },
"node_modules/undici": { "node_modules/undici": {
"version": "6.23.0", "version": "6.25.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz",
"integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==", "integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18.17" "node": ">=18.17"
@@ -12772,9 +12759,9 @@
} }
}, },
"node_modules/wonka": { "node_modules/wonka": {
"version": "6.3.5", "version": "6.3.6",
"resolved": "https://registry.npmjs.org/wonka/-/wonka-6.3.5.tgz", "resolved": "https://registry.npmjs.org/wonka/-/wonka-6.3.6.tgz",
"integrity": "sha512-SSil+ecw6B4/Dm7Pf2sAshKQ5hWFvfyGlfPbEd6A14dOH6VDjrmbY86u6nZvy9omGwwIPFR8V41+of1EezgoUw==", "integrity": "sha512-MXH+6mDHAZ2GuMpgKS055FR6v0xVP3XwquxIMYXgiW+FejHQlMGlvVRZT4qMCxR+bEo/FCtIdKxwej9WV3YQag==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/word-wrap": { "node_modules/word-wrap": {

View File

@@ -1,7 +1,10 @@
{ {
"name": "mobile", "name": "mobile",
"main": "expo-router/entry", "main": "expo-router/entry",
"version": "1.0.0", "version": "2.0.0",
"engines": {
"node": "22.x"
},
"scripts": { "scripts": {
"start": "expo start --dev-client --host lan", "start": "expo start --dev-client --host lan",
"reset-project": "node ./scripts/reset-project.js", "reset-project": "node ./scripts/reset-project.js",
@@ -10,30 +13,34 @@
"ios:device": "LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 expo run:ios --device", "ios:device": "LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 expo run:ios --device",
"web": "expo start --web", "web": "expo start --web",
"lint": "expo lint", "lint": "expo lint",
"doctor": "npx expo-doctor",
"preflight:testflight": "npm run lint && npm run doctor",
"build:ios:dev": "eas build --profile development --platform ios", "build:ios:dev": "eas build --profile development --platform ios",
"build:ios:preview": "eas build --profile preview --platform ios" "build:ios:preview": "eas build --profile preview --platform ios",
"build:ios:testflight": "eas build --profile testflight --platform ios",
"submit:ios:testflight": "eas submit --profile testflight --platform ios --latest"
}, },
"dependencies": { "dependencies": {
"@expo/vector-icons": "^15.0.3", "@expo/vector-icons": "^15.0.3",
"@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",
"expo": "~54.0.33", "expo": "~54.0.34",
"expo-camera": "~17.0.10", "expo-camera": "~17.0.10",
"expo-constants": "~18.0.13", "expo-constants": "~18.0.13",
"expo-document-picker": "^14.0.8", "expo-document-picker": "^14.0.8",
"expo-font": "~14.0.11", "expo-font": "~14.0.11",
"expo-haptics": "~15.0.8", "expo-haptics": "~15.0.8",
"expo-image": "~3.0.11", "expo-image": "~3.0.11",
"expo-image-picker": "~17.0.8", "expo-image-picker": "~17.0.11",
"expo-linking": "~8.0.11", "expo-linking": "~8.0.12",
"expo-router": "~6.0.23", "expo-router": "~6.0.23",
"expo-secure-store": "^15.0.8", "expo-secure-store": "^15.0.8",
"expo-splash-screen": "~31.0.13", "expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.9", "expo-status-bar": "~3.0.9",
"expo-symbols": "~1.0.8", "expo-symbols": "~1.0.8",
"expo-system-ui": "~6.0.9", "expo-system-ui": "~6.0.9",
"expo-web-browser": "~15.0.10", "expo-web-browser": "~15.0.11",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-native": "0.81.5", "react-native": "0.81.5",
@@ -43,7 +50,7 @@
"react-native-safe-area-context": "~5.6.0", "react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0", "react-native-screens": "~4.16.0",
"react-native-web": "~0.21.0", "react-native-web": "~0.21.0",
"react-native-webview": "^13.16.0", "react-native-webview": "13.15.0",
"react-native-worklets": "0.5.1" "react-native-worklets": "0.5.1"
}, },
"devDependencies": { "devDependencies": {

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

View File

@@ -183,6 +183,100 @@ type RequestOptions = {
body?: unknown; body?: unknown;
}; };
export type MatrixStatus = {
enabled?: boolean;
ready?: boolean;
configured?: boolean;
homeserverUrl?: string | null;
[key: string]: unknown;
};
export type MatrixIdentity = {
matrixUserId: string;
displayName?: string | null;
};
export type MatrixRoom = {
key: string;
name: string;
topic?: string | null;
type?: 'room' | 'project' | 'direct' | string;
group?: string;
roomId?: string | null;
alias?: string | null;
exists?: boolean;
projectId?: number;
projectNumber?: string | null;
userId?: string;
email?: string | null;
entityType?: string | null;
entityId?: number | null;
entityUuid?: string | null;
unread?: number;
mentions?: number;
provisionEndpoint?: string;
[key: string]: unknown;
};
export type MatrixAttachment = {
fileName?: string | null;
mimeType?: string | null;
size?: number | null;
mxcUri?: string | null;
previewUrl?: string | null;
downloadUrl?: string | null;
};
export type MatrixReaction = {
key: string;
count?: number;
own?: boolean;
senders?: string[];
[key: string]: unknown;
};
export type MatrixMessage = {
id: string;
sender: string;
senderDisplayName?: string | null;
body?: string | null;
timestamp?: string | number | null;
own?: boolean;
edited?: boolean;
redacted?: boolean;
msgtype?: string;
attachment?: MatrixAttachment | null;
replyToEventId?: string | null;
reactions?: MatrixReaction[];
[key: string]: unknown;
};
export type MatrixMember = {
matrixUserId: string;
displayName?: string | null;
avatarUrl?: string | null;
membership?: string;
[key: string]: unknown;
};
export type MatrixUser = {
userId: string;
matrixUserId: string;
displayName?: string | null;
email?: string | null;
[key: string]: unknown;
};
export type MatrixSyncResponse = {
nextBatch?: string;
messages?: MatrixMessage[];
replacements?: MatrixMessage[];
reactions?: (MatrixReaction & { targetEventId?: string })[];
redactions?: { redacts?: string; eventId?: string; targetEventId?: string }[];
members?: MatrixMember[];
[key: string]: unknown;
};
function buildUrl(path: string): string { function buildUrl(path: string): string {
if (path.startsWith('http://') || path.startsWith('https://')) { if (path.startsWith('http://') || path.startsWith('https://')) {
return path; return path;
@@ -227,10 +321,231 @@ export async function apiRequest<T>(path: string, options: RequestOptions = {}):
return payload as T; return payload as T;
} }
async function apiFormRequest<T>(path: string, token: string, formData: FormData): Promise<T> {
const response = await fetch(buildUrl(path), {
method: 'POST',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`,
},
body: formData,
});
const payload = await parseJson(response);
if (!response.ok) {
const message =
(payload as { message?: string; error?: string } | null)?.message ||
(payload as { message?: string; error?: string } | null)?.error ||
`Request failed (${response.status}) for ${path}`;
throw new Error(message);
}
return payload as T;
}
function matrixRoomPath(roomKey: string, suffix = ''): string {
return `/api/communication/matrix/rooms/${encodeURIComponent(roomKey)}${suffix}`;
}
export async function checkBackendHealth(): Promise<{ status: string; [key: string]: unknown }> { export async function checkBackendHealth(): Promise<{ status: string; [key: string]: unknown }> {
return apiRequest<{ status: string; [key: string]: unknown }>('/health'); return apiRequest<{ status: string; [key: string]: unknown }>('/health');
} }
export async function fetchMatrixStatus(token: string): Promise<MatrixStatus> {
return apiRequest<MatrixStatus>('/api/communication/matrix/status', { token });
}
export async function fetchMatrixIdentity(token: string): Promise<MatrixIdentity> {
return apiRequest<MatrixIdentity>('/api/communication/matrix/me', { token });
}
export async function provisionMatrixUser(token: string): Promise<MatrixIdentity> {
return apiRequest<MatrixIdentity>('/api/communication/matrix/me/provision', {
method: 'POST',
token,
});
}
export async function fetchMatrixRooms(token: string): Promise<MatrixRoom[]> {
const [rooms, projectRooms, directRooms, unread] = await Promise.all([
apiRequest<{ rooms?: MatrixRoom[] }>('/api/communication/matrix/rooms', { token }),
apiRequest<{ rooms?: MatrixRoom[] }>('/api/communication/matrix/project-rooms', { token }),
apiRequest<{ rooms?: MatrixRoom[] }>('/api/communication/matrix/direct-rooms', { token }),
apiRequest<{ rooms?: Record<string, { count?: number; mentions?: number }> }>('/api/communication/matrix/unread', {
token,
}),
]);
const unreadByRoom = unread.rooms || {};
const decorate = (room: MatrixRoom, group: string): MatrixRoom => ({
...room,
group,
unread: unreadByRoom[room.key]?.count || 0,
mentions: unreadByRoom[room.key]?.mentions || 0,
});
return [
...(rooms.rooms || []).map((room) => decorate(room, 'Räume')),
...(projectRooms.rooms || []).map((room) => decorate(room, 'Projekte')),
...(directRooms.rooms || []).map((room) => decorate(room, 'Direkt')),
];
}
export async function fetchMatrixUsers(token: string): Promise<MatrixUser[]> {
const response = await apiRequest<{ users?: MatrixUser[] }>('/api/communication/matrix/users', { token });
return response.users || [];
}
export async function createMatrixRoom(
token: string,
payload: { key: string; name: string; topic?: string | null; type?: string }
): Promise<MatrixRoom> {
return apiRequest<MatrixRoom>('/api/communication/matrix/rooms', {
method: 'POST',
token,
body: payload,
});
}
export async function provisionMatrixRoom(token: string, room: MatrixRoom): Promise<MatrixRoom> {
if (room.provisionEndpoint) {
return apiRequest<MatrixRoom>(room.provisionEndpoint, { method: 'POST', token });
}
if (room.type === 'project' && room.projectId) {
return apiRequest<MatrixRoom>(`/api/communication/matrix/project-rooms/${room.projectId}/provision`, {
method: 'POST',
token,
});
}
if (room.type === 'direct' && room.userId) {
return apiRequest<MatrixRoom>(`/api/communication/matrix/direct-rooms/${encodeURIComponent(room.userId)}/provision`, {
method: 'POST',
token,
});
}
return apiRequest<MatrixRoom>(matrixRoomPath(room.key, '/provision'), {
method: 'POST',
token,
body: {
key: room.key,
name: room.name,
topic: room.topic,
type: room.type || 'room',
entityType: room.entityType,
entityId: room.entityId,
entityUuid: room.entityUuid,
},
});
}
export async function fetchMatrixMessages(token: string, roomKey: string): Promise<MatrixMessage[]> {
const response = await apiRequest<{ messages?: MatrixMessage[] }>(matrixRoomPath(roomKey, '/messages'), { token });
return response.messages || [];
}
export async function syncMatrixRoom(
token: string,
roomKey: string,
since?: string,
initial = false
): Promise<MatrixSyncResponse> {
const query = new URLSearchParams();
if (since) query.set('since', since);
if (initial) query.set('initial', '1');
const suffix = query.toString() ? `/sync?${query.toString()}` : '/sync';
return apiRequest<MatrixSyncResponse>(matrixRoomPath(roomKey, suffix), { token });
}
export async function fetchMatrixMembers(token: string, roomKey: string): Promise<MatrixMember[]> {
const response = await apiRequest<{ members?: MatrixMember[] }>(matrixRoomPath(roomKey, '/members'), { token });
return response.members || [];
}
export async function sendMatrixMessage(
token: string,
roomKey: string,
text: string,
replyToEventId?: string | null
): Promise<MatrixMessage> {
return apiRequest<MatrixMessage>(matrixRoomPath(roomKey, '/messages'), {
method: 'POST',
token,
body: { text, replyToEventId },
});
}
export async function editMatrixMessage(token: string, roomKey: string, eventId: string, text: string): Promise<MatrixMessage> {
return apiRequest<MatrixMessage>(matrixRoomPath(roomKey, `/messages/${encodeURIComponent(eventId)}`), {
method: 'PUT',
token,
body: { text },
});
}
export async function deleteMatrixMessage(token: string, roomKey: string, eventId: string): Promise<void> {
await apiRequest(matrixRoomPath(roomKey, `/messages/${encodeURIComponent(eventId)}`), {
method: 'DELETE',
token,
});
}
export async function reactToMatrixMessage(token: string, roomKey: string, eventId: string, key: string): Promise<void> {
await apiRequest(matrixRoomPath(roomKey, `/messages/${encodeURIComponent(eventId)}/reactions`), {
method: 'POST',
token,
body: { key },
});
}
export async function markMatrixRoomRead(token: string, roomKey: string, eventId?: string): Promise<void> {
await apiRequest(matrixRoomPath(roomKey, '/read'), {
method: 'POST',
token,
body: { eventId },
});
}
export async function syncMatrixMembers(token: string, roomKey: string): Promise<void> {
await apiRequest(matrixRoomPath(roomKey, '/members/sync'), {
method: 'POST',
token,
});
}
export async function inviteMatrixMember(token: string, roomKey: string, userId: string): Promise<void> {
await apiRequest(matrixRoomPath(roomKey, '/members/invite'), {
method: 'POST',
token,
body: { userId },
});
}
export async function removeMatrixMember(token: string, roomKey: string, matrixUserId: string): Promise<void> {
await apiRequest(matrixRoomPath(roomKey, `/members/${encodeURIComponent(matrixUserId)}`), {
method: 'DELETE',
token,
});
}
export async function uploadMatrixAttachment(
token: string,
roomKey: string,
file: { uri: string; name: string; mimeType?: string | null }
): Promise<MatrixMessage> {
const formData = new FormData();
formData.append('file', {
uri: file.uri,
name: file.name,
type: file.mimeType || 'application/octet-stream',
} as unknown as Blob);
return apiFormRequest<MatrixMessage>(matrixRoomPath(roomKey, '/attachments'), token, formData);
}
export async function renderPrintLabel( export async function renderPrintLabel(
token: string, token: string,
context: Record<string, unknown>, context: Record<string, unknown>,
@@ -508,7 +823,7 @@ export async function createCustomerInventoryItem(
} }
export async function fetchPlants(token: string, includeArchived = false): Promise<Plant[]> { export async function fetchPlants(token: string, includeArchived = false): Promise<Plant[]> {
const plants = await apiRequest<Array<Plant & { description?: unknown }>>('/api/resource/plants', { token }); const plants = await apiRequest<(Plant & { description?: unknown })[]>('/api/resource/plants', { token });
const normalized = (plants || []).map((plant) => { const normalized = (plants || []).map((plant) => {
const legacyDescription = typeof plant.description === 'string' const legacyDescription = typeof plant.description === 'string'
? plant.description ? plant.description

View File

@@ -207,7 +207,7 @@ FEDEO Selfhost Setup
Dieses Script führt dich durch die lokale Betriebsstruktur: Dieses Script führt dich durch die lokale Betriebsstruktur:
$ROOT_DIR/ $ROOT_DIR/
docker-compose.selfhost.yml Docker Stack für FEDEO, Traefik, PostgreSQL, MinIO und Matrix docker-compose.selfhost.yml Docker Stack für FEDEO, Traefik, PostgreSQL, MinIO, Matrix und Monitoring
.env Zielkonfiguration, wird von diesem Script geschrieben .env Zielkonfiguration, wird von diesem Script geschrieben
postgres/ persistente FEDEO-Datenbank postgres/ persistente FEDEO-Datenbank
minio/ lokaler S3-kompatibler Dateispeicher minio/ lokaler S3-kompatibler Dateispeicher
@@ -320,6 +320,7 @@ DOKUBOX_IMAP_PASSWORD=$(env_quote "$dokubox_password")
OPENAI_API_KEY=$(env_quote "$openai_key") OPENAI_API_KEY=$(env_quote "$openai_key")
STIRLING_API_KEY=$(env_quote "$stirling_key") STIRLING_API_KEY=$(env_quote "$stirling_key")
NUXT_PUBLIC_PDF_LICENSE=$(env_quote "$pdf_license") NUXT_PUBLIC_PDF_LICENSE=$(env_quote "$pdf_license")
NODE_EXPORTER_URL=$(env_quote "http://node-exporter:9100")
FEDEO_BOOTSTRAP_ADMIN_EMAIL=$(env_quote "$admin_email") FEDEO_BOOTSTRAP_ADMIN_EMAIL=$(env_quote "$admin_email")
FEDEO_BOOTSTRAP_ADMIN_PASSWORD=$(env_quote "$admin_password") FEDEO_BOOTSTRAP_ADMIN_PASSWORD=$(env_quote "$admin_password")
@@ -484,7 +485,11 @@ main() {
else else
echo echo
echo "Start später mit:" echo "Start später mit:"
echo " docker compose -f docker-compose.selfhost.yml up -d" if [[ "${FEDEO_USE_SUDO_DOCKER:-false}" == "true" ]]; then
echo " sudo docker compose -f $COMPOSE_FILE up -d"
else
echo " docker compose -f $COMPOSE_FILE up -d"
fi
fi fi
} }

View File

@@ -348,6 +348,7 @@ footer {
.workflow-section, .workflow-section,
.open-source-section, .open-source-section,
.script-section,
.contact-section { .contact-section {
align-items: start; align-items: start;
display: grid; display: grid;
@@ -423,6 +424,86 @@ footer {
justify-self: end; justify-self: end;
} }
.script-section {
border-top: 1px solid rgba(23, 33, 31, 0.12);
display: block;
margin-left: auto;
margin-right: auto;
max-width: 920px;
padding-bottom: 5rem;
padding-top: 4rem;
}
.script-section p {
color: #51605c;
font-size: 1.08rem;
line-height: 1.7;
}
.script-section > div:first-child {
max-width: 42rem;
}
.code-window {
background: #17211f;
border: 1px solid rgba(255, 255, 255, 0.22);
border-radius: 0.5rem;
box-shadow: 0 1.5rem 4rem rgba(23, 33, 31, 0.22);
color: #dff8d8;
margin-top: 2rem;
overflow: hidden;
width: 100%;
}
.code-window-toolbar {
align-items: center;
background: #24302d;
display: flex;
gap: 0.5rem;
min-height: 3rem;
padding: 0 1rem;
}
.code-window-toolbar span {
background: rgba(255, 255, 255, 0.7);
border-radius: 50%;
display: block;
height: 0.68rem;
width: 0.68rem;
}
.code-window-toolbar span:nth-child(2) {
background: var(--accent);
}
.code-window-toolbar strong {
color: rgba(255, 255, 255, 0.68);
font-size: 0.86rem;
margin-left: 0.4rem;
}
.code-window pre {
margin: 0;
overflow-x: auto;
padding: clamp(1.25rem, 3vw, 2rem);
}
.code-window code {
color: #dff8d8;
display: block;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: clamp(0.92rem, 1.7vw, 1rem);
line-height: 1.8;
white-space: pre-wrap;
word-break: break-word;
}
.code-window code::before {
color: var(--accent);
content: "$ ";
font-weight: 800;
}
.contact-section { .contact-section {
background: #ffffff; background: #ffffff;
border: 1px solid rgba(23, 33, 31, 0.1); border: 1px solid rgba(23, 33, 31, 0.1);
@@ -522,6 +603,7 @@ footer p {
.hero-section, .hero-section,
.workflow-section, .workflow-section,
.open-source-section, .open-source-section,
.script-section,
.contact-section { .contact-section {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }

View File

@@ -8,6 +8,7 @@
<nav aria-label="Hauptnavigation"> <nav aria-label="Hauptnavigation">
<NuxtLink to="/#funktionen">Funktionen</NuxtLink> <NuxtLink to="/#funktionen">Funktionen</NuxtLink>
<NuxtLink to="/#open-source">Open Source</NuxtLink> <NuxtLink to="/#open-source">Open Source</NuxtLink>
<NuxtLink to="/#selfhost">Selfhost</NuxtLink>
<NuxtLink to="/#kontakt">Kontakt</NuxtLink> <NuxtLink to="/#kontakt">Kontakt</NuxtLink>
<a class="login-link" href="https://app.fedeo.de">Einloggen</a> <a class="login-link" href="https://app.fedeo.de">Einloggen</a>
</nav> </nav>

View File

@@ -8,6 +8,7 @@
<nav aria-label="Hauptnavigation"> <nav aria-label="Hauptnavigation">
<a href="#funktionen">Funktionen</a> <a href="#funktionen">Funktionen</a>
<a href="#open-source">Open Source</a> <a href="#open-source">Open Source</a>
<a href="#selfhost">Selfhost</a>
<a href="#kontakt">Kontakt</a> <a href="#kontakt">Kontakt</a>
<a class="login-link" href="https://app.fedeo.de">Einloggen</a> <a class="login-link" href="https://app.fedeo.de">Einloggen</a>
</nav> </nav>
@@ -130,6 +131,26 @@
<a class="secondary-action" href="https://git.federspiel.tech/flfeders/FEDEO">Repository ansehen</a> <a class="secondary-action" href="https://git.federspiel.tech/flfeders/FEDEO">Repository ansehen</a>
</section> </section>
<section id="selfhost" class="script-section">
<div>
<p class="eyebrow">Selfhosting</p>
<h2>FEDEO auf deinem Server starten.</h2>
<p>
Der Installer prüft die Basisumgebung, richtet Docker bei Bedarf ein, lädt FEDEO und startet danach den geführten Selfhost-Assistenten.
</p>
</div>
<div class="code-window" aria-label="FEDEO Selfhost Installationsbefehl">
<div class="code-window-toolbar">
<span></span>
<span></span>
<span></span>
<strong>Terminal</strong>
</div>
<pre><code>{{ installCommand }}</code></pre>
</div>
</section>
<section id="kontakt" class="contact-section"> <section id="kontakt" class="contact-section">
<div> <div>
<p class="eyebrow">Jetzt anfragen</p> <p class="eyebrow">Jetzt anfragen</p>
@@ -196,4 +217,6 @@ const features = [
description: 'FEDEO ist für Arbeit am Schreibtisch und für mobile Abläufe im Einsatz vorbereitet.' description: 'FEDEO ist für Arbeit am Schreibtisch und für mobile Abläufe im Einsatz vorbereitet.'
} }
] ]
const installCommand = 'curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/scripts/selfhost-install.sh | bash -s -- --simple --start'
</script> </script>