Skip to content

Craft Easy Admin — Generisk CRUD Admin-applikation

Version: 2.0 Datum: 2026-03-27 Relaterat: specification.md (sektion 16)


Innehållsförteckning

  1. Vision
  2. Arkitektur
  3. Teknisk stack
  4. Multi-session (flera API:er samtidigt)
  5. Admin-schema protokoll
  6. Applikationsstruktur
  7. Vyer och komponenter
  8. Relation-hantering
  9. Autentisering och rättigheter
  10. Realtidsupplevelse och ETag
  11. Tema och anpassning
  12. Plattformsspecifikt
  13. Testning
  14. Deployment
  15. Implementationsplan

1. Vision

Vad

En universell admin-applikation som bygger komplett CRUD-gränssnitt automatiskt genom att läsa ett API:s admin-schema. Fungerar som webb-app, iOS-app och Android-app från en enda kodbas. Stödjer flera API-instanser samtidigt med separata sessioner.

Nyckelfeatures

Feature Beskrivning
Schema-driven Peka på API URL → komplett admin genereras
Multi-session Spara favorit-API:er, inloggad i alla samtidigt, växla utan utloggning
Universal Webb + iOS + Android från samma kodbas
Noll konfiguration Fungerar dag 1 utan anpassning. AdminConfig ger extra polish.

Designprinciper

Princip Regel
En kodbas, tre plattformar React + Expo → webb, iOS, Android
Schema-driven All UI genereras från /admin/schema. Ingen hårdkodad logik.
Multi-session first Arkitekturen designas för flera samtidiga API-anslutningar från start
Mobil-först Touch-optimerad, fungerar på 375px bredd
Offline-medveten Schema cachas, listor cachas, formulär kräver uppkoppling

2. Arkitektur

Övergripande

┌─────────────────────────────────────────────────────────────────┐
│                     Craft Easy Admin (Expo)                              │
│                                                                   │
│  ┌─ Session Manager ──────────────────────────────────────────┐  │
│  │                                                             │  │
│  │  ┌────────────┐  ┌────────────┐  ┌────────────┐           │  │
│  │  │ Airpark    │  │ Hobby 1    │  │ Hobby 2    │  + Lägg   │  │
│  │  │ api.air..  │  │ api.hob..  │  │ local:8000 │    till   │  │
│  │  │ ● online   │  │ ● online   │  │ ○ offline  │           │  │
│  │  └─────┬──────┘  └────────────┘  └────────────┘           │  │
│  └────────┼───────────────────────────────────────────────────┘  │
│           │ aktiv session                                         │
│  ┌────────▼───────────────────────────────────────────────────┐  │
│  │  Schema Loader                                              │  │
│  │  GET /admin/schema → cache → parse → generera UI            │  │
│  └────────┬───────────────────────────────────────────────────┘  │
│           │                                                       │
│  ┌────────▼───────────────────────────────────────────────────┐  │
│  │  Dynamic Router (Expo Router)                               │  │
│  │                                                              │  │
│  │  /                           → Session Switcher / Dashboard  │  │
│  │  /[resource]                 → ListView                      │  │
│  │  /[resource]/create          → CreateView                    │  │
│  │  /[resource]/[id]            → DetailView                    │  │
│  │  /[resource]/[id]/edit       → EditView                      │  │
│  └──────────────────────────────────────────────────────────────┘  │
│                                                                     │
│  ┌─ UI Layer ─────────────────────────────────────────────────┐    │
│  │  Webb: shadcn/ui (HTML)    Native: React Native komponenter │    │
│  │  Delat: hooks, state, API-klient, schema-parser             │    │
│  └─────────────────────────────────────────────────────────────┘    │
└──────────────────────────────┬──────────────────────────────────────┘
                               │ HTTP (REST)
          ┌────────────────────┼──────────────────────┐
          ▼                    ▼                       ▼
┌──────────────┐    ┌──────────────┐       ┌──────────────┐
│  Airpark API │    │  Hobby 1 API │       │  Hobby 2 API │
│  (Cosmos)    │    │  (Cosmos)    │       │  (localhost)  │
└──────────────┘    └──────────────┘       └──────────────┘

Dataflöde

1. Användare öppnar appen → ser sparade API-anslutningar
2. Väljer/lägger till API URL
3. App hämtar GET /admin/schema → cache:ar lokalt
4. Schema parsas → navigation, listor, formulär genereras
5. Användare navigerar → GET /{resource}?page=1&per_page=25
6. Användare redigerar → GET /{resource}/{id} (+ ETag)
7. Användare sparar → PATCH med If-Match: ETag
8. Byter API → appen laddar annat schema, annan session, annan token

3. Teknisk stack

Varför React + Expo?

Krav Expo ger
Webb + iOS + Android En kodbas → alla tre plattformar
App Store / Google Play Expo EAS Build → native binärer
OTA-uppdateringar Expo Updates → uppdatera utan ny store-release
Multi-session state Zustand/MMKV → snabb persistent lagring
Offline Expo FileSystem + TanStack Query cache
Push-notiser (framtida) Expo Notifications

Stack

Lager Teknik Motivering
Runtime React 19 + Expo SDK 53 Universal platform (webb + native)
Routing Expo Router v4 File-based, type-safe, deep linking, webb + native
UI (webb) Tamagui eller gluestack-ui Universal komponenter (renderar HTML på webb, native på mobil)
State Zustand Lättviktigt, TypeScript-first, fungerar överallt
Persistent state MMKV (native) / localStorage (webb) Sessioner, tokens, favoriter
Server state TanStack Query v5 Caching, revalidation, optimistic updates
Tabeller TanStack Table v8 Headless — renderar med vilken UI som helst
Formulär React Hook Form + Zod Best-in-class, genererar från schema
HTTP ky (webb) / native fetch Lättviktigt, interceptors, retry
Ikoner @expo/vector-icons + Lucide Fungerar native + webb
Datum date-fns Lättviktigt
CSS (webb) NativeWind (Tailwind för RN) Samma utility-klasser som Tailwind

Varför inte shadcn/ui direkt?

shadcn/ui är HTML-baserat (div, input, etc.) — fungerar inte i React Native. Istället:

Approach Beskrivning
Tamagui Universal komponenter: renderar <div> på webb, <View> på native. Tailwind-liknande styling. Bästa performance.
gluestack-ui v2 Universal, byggt på NativeWind. Enklare API men inte lika snabbt som Tamagui.
Platform-specifika filer DataTable.web.tsx + DataTable.native.tsx — Expo väljer rätt automatiskt

Rekommendation: Tamagui — mest mogen universal UI-lösning för Expo.

Widget-mappning

Admin-schema widget Komponent Webb Native
text TextInput <input> <TextInput>
textarea TextArea <textarea> <TextInput multiline>
number NumberInput <input type=number> <TextInput keyboardType=numeric>
select Select <select> / dropdown Bottom sheet picker
boolean Switch Toggle switch Toggle switch
date DatePicker HTML date input Native date picker
datetime DateTimePicker HTML datetime input Native datetime picker
email TextInput <input type=email> <TextInput keyboardType=email>
relation RelationPicker Searchable dropdown Bottom sheet + search
media MediaUpload File input + drag expo-image-picker
json JsonEditor Monaco (webb only) Read-only / TextInput
markdown MarkdownEditor Rikt webb-editor Enklare native editor

4. Multi-session

4.1 Koncept

Användaren kan ha flera API-anslutningar sparade och vara inloggad i alla samtidigt. Växla mellan dem utan att logga ut.

4.2 Session-modell

// types/session.ts
interface ApiSession {
  id: string                        // UUID
  url: string                       // "https://api.airpark.com"
  name: string                      // "Airpark" (från admin-schema title)
  token: string | null              // JWT — null om utloggad
  tokenExpiry: number | null        // Unix timestamp
  refreshToken: string | null
  schema: AdminSchema | null        // Cachad schema
  schemaUpdatedAt: number | null    // När schema senast hämtades
  theme: {                          // Från API:t
    primaryColor: string
    logoUrl?: string
  } | null
  lastUsed: number                  // Senaste användningstid (för sortering)
}

4.3 Session Store

// stores/sessions.ts
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
import { mmkvStorage } from './mmkv'  // MMKV för native, localStorage för webb

interface SessionStore {
  sessions: ApiSession[]
  activeSessionId: string | null

  // Actions
  addSession: (url: string) => Promise<void>
  removeSession: (id: string) => void
  switchSession: (id: string) => void
  updateToken: (id: string, token: string, expiry: number) => void
  updateSchema: (id: string, schema: AdminSchema) => void

  // Computed
  activeSession: () => ApiSession | null
  activeSessions: () => ApiSession[]  // Alla med giltig token
}

export const useSessionStore = create<SessionStore>()(
  persist(
    (set, get) => ({
      sessions: [],
      activeSessionId: null,

      addSession: async (url: string) => {
        // 1. Hämta schema (utan auth — för att få title + theme)
        const schema = await fetch(`${url}/admin/schema`).then(r => r.json())

        const session: ApiSession = {
          id: crypto.randomUUID(),
          url,
          name: schema.title,
          token: null,
          tokenExpiry: null,
          refreshToken: null,
          schema,
          schemaUpdatedAt: Date.now(),
          theme: schema.theme || null,
          lastUsed: Date.now(),
        }

        set(state => ({
          sessions: [...state.sessions, session],
          activeSessionId: session.id,
        }))
      },

      switchSession: (id: string) => {
        set(state => ({
          activeSessionId: id,
          sessions: state.sessions.map(s =>
            s.id === id ? { ...s, lastUsed: Date.now() } : s
          ),
        }))
      },

      activeSession: () => {
        const state = get()
        return state.sessions.find(s => s.id === state.activeSessionId) || null
      },

      activeSessions: () => {
        return get().sessions.filter(s => s.token !== null)
      },

      // ... övriga actions
    }),
    {
      name: 'craft-easy-admin-sessions',
      storage: createJSONStorage(() => mmkvStorage),
    }
  )
)

4.4 API-klient med session-kontext

// lib/api-client.ts
import { useSessionStore } from '../stores/sessions'

export function createApiClient(sessionId?: string) {
  const getSession = () => {
    const store = useSessionStore.getState()
    return sessionId
      ? store.sessions.find(s => s.id === sessionId)
      : store.activeSession()
  }

  return {
    fetch: async (path: string, options: RequestInit = {}) => {
      const session = getSession()
      if (!session) throw new Error('No active session')

      const headers = new Headers(options.headers)

      // Auth
      if (session.token) {
        headers.set('X-API-Key', session.token)
      }

      const response = await fetch(`${session.url}${path}`, {
        ...options,
        headers,
      })

      // Auto-refresh om token snart går ut
      if (session.tokenExpiry && session.tokenExpiry - Date.now() < 5 * 60 * 1000) {
        refreshToken(session)
      }

      return response
    }
  }
}

4.5 Session Switcher UI

// components/SessionSwitcher.tsx
export function SessionSwitcher() {
  const { sessions, activeSessionId, switchSession } = useSessionStore()

  return (
    <ScrollView horizontal>
      {sessions.map(session => (
        <Pressable
          key={session.id}
          onPress={() => switchSession(session.id)}
          style={[
            styles.sessionCard,
            session.id === activeSessionId && styles.activeCard
          ]}
        >
          {session.theme?.logoUrl && (
            <Image source={{ uri: session.theme.logoUrl }} style={styles.logo} />
          )}
          <Text style={styles.name}>{session.name}</Text>
          <View style={[
            styles.statusDot,
            { backgroundColor: session.token ? '#22c55e' : '#9ca3af' }
          ]} />
        </Pressable>
      ))}
      <Pressable onPress={showAddDialog} style={styles.addCard}>
        <Ionicons name="add" size={24} />
      </Pressable>
    </ScrollView>
  )
}

5. Admin-schema protokoll

Endpoint

GET /admin/schema
Authorization: X-API-Key: <jwt>    (valfritt — grundschema kan vara publikt)

Response

interface AdminSchema {
  version: string                    // "1.0"
  title: string                      // "Airpark API"
  theme?: ThemeConfig
  auth: AuthConfig
  navigation: NavigationGroup[]
  resources: ResourceSchema[]
}

interface ThemeConfig {
  primary_color: string              // "#2563eb"
  logo_url?: string
  favicon_url?: string
}

interface AuthConfig {
  login_endpoint: string             // "/auth/login/api-code"
  refresh_endpoint: string           // "/auth/refresh"
  token_header: string               // "X-API-Key"
}

interface NavigationGroup {
  label: string
  icon?: string
  resources: string[]
}

interface ResourceSchema {
  endpoint: string                   // "/parking-zones"
  name: string                       // "parking-zones"
  label: string                      // "Parking Zone"
  label_plural: string               // "Parking Zones"
  icon?: string
  description?: string
  methods: string[]                  // ["GET", "POST", "PATCH", "DELETE"]
  list: ListConfig
  form: FormConfig
  fields: Record<string, FieldSchema>
  features: ResourceFeatures
}

interface ListConfig {
  fields: string[]
  search_fields: string[]
  filter_fields: string[]
  sort_default: string
  sort_fields: string[]
  page_size: number
}

interface FormConfig {
  groups: FormGroup[]
}

interface FormGroup {
  label: string
  fields: string[]
  collapsible?: boolean
  collapsed?: boolean
}

interface FieldSchema {
  type: string                       // "string" | "integer" | "number" | "boolean" |
                                     // "datetime" | "objectid" | "array" | "object"
  required: boolean
  readonly: boolean
  widget: string                     // "text" | "textarea" | "number" | "select" |
                                     // "boolean" | "date" | "datetime" | "email" |
                                     // "relation" | "media" | "json" | "markdown" |
                                     // "color" | "url" | "hidden"
  label?: string
  placeholder?: string
  help_text?: string
  min?: number
  max?: number
  step?: number
  min_length?: number
  max_length?: number
  pattern?: string
  options?: SelectOption[]
  relation?: RelationConfig
  accept?: string
  max_size_mb?: number
}

interface SelectOption {
  value: string
  label: string
  color?: string
  icon?: string
}

interface RelationConfig {
  endpoint: string
  label_field: string
  search_field: string
  value_field: string
  filters?: Record<string, string>
}

interface ResourceFeatures {
  create: boolean
  edit: boolean
  delete: boolean
  export: boolean
  bulk_delete: boolean
  duplicate: boolean
}

Fallback: Auto-schema från OpenAPI

Om en resurs saknar AdminConfig genereras schemat automatiskt:

OpenAPI Widget
type: string text
type: string, format: email email
type: string, format: date-time datetime
type: integer / number number
type: boolean boolean
type: string, enum: [...] select
$ref ObjectId relation (gissning)

6. Applikationsstruktur

craft-easy-admin/
├── app.json                          # Expo-konfiguration
├── package.json
├── tsconfig.json
├── tailwind.config.ts                # NativeWind
├── tamagui.config.ts                 # Tamagui theme
├── app/                              # Expo Router (file-based routing)
│   ├── _layout.tsx                   # Root layout (providers, session)
│   ├── index.tsx                     # Start: Session switcher / Dashboard
│   ├── login.tsx                     # Login för aktiv session
│   ├── add-api.tsx                   # Lägg till ny API-anslutning
│   │
│   └── (admin)/                      # Admin-grupp (kräver aktiv session)
│       ├── _layout.tsx               # Admin layout (sidebar/tabs + content)
│       ├── index.tsx                 # Dashboard
│       └── [resource]/               # Dynamisk resurs-routing
│           ├── index.tsx             # ListView
│           ├── create.tsx            # CreateView
│           └── [id]/
│               ├── index.tsx         # DetailView
│               └── edit.tsx          # EditView
├── components/
│   ├── sessions/
│   │   ├── SessionSwitcher.tsx       # Horisontell lista med API-sessions
│   │   ├── SessionCard.tsx           # Kort för en session (logo, status)
│   │   └── AddApiDialog.tsx          # Modal för att lägga till API URL
│   │
│   ├── admin/
│   │   ├── Sidebar.tsx               # Navigation — webb: sidebar, native: drawer
│   │   ├── Sidebar.web.tsx           # Webb-specifik sidebar (valfritt)
│   │   ├── BottomTabs.native.tsx     # Native-specifik bottom navigation
│   │   ├── Header.tsx                # Topbar med session-switcher
│   │   └── Dashboard.tsx             # Räknare, senaste ändringar
│   │
│   ├── list/
│   │   ├── DataTable.tsx             # Tabell (webb: HTML table, native: FlatList)
│   │   ├── DataTable.web.tsx         # Webb: TanStack Table → HTML
│   │   ├── DataTable.native.tsx      # Native: FlatList med kort-layout
│   │   ├── FilterBar.tsx
│   │   ├── SearchInput.tsx
│   │   ├── CellRenderer.tsx
│   │   ├── Pagination.tsx
│   │   └── ListItem.native.tsx       # Native: listelement som kort
│   │
│   ├── form/
│   │   ├── FormBuilder.tsx           # Schema → formulär
│   │   ├── FieldRenderer.tsx         # Väljer rätt widget
│   │   ├── FieldGroup.tsx
│   │   └── FormActions.tsx
│   │
│   └── widgets/
│       ├── TextInput.tsx
│       ├── TextArea.tsx
│       ├── NumberInput.tsx
│       ├── SelectInput.tsx           # Webb: dropdown, native: bottom sheet
│       ├── BooleanSwitch.tsx
│       ├── DatePicker.tsx            # Webb: HTML input, native: expo datepicker
│       ├── DateTimePicker.tsx
│       ├── EmailInput.tsx
│       ├── UrlInput.tsx
│       ├── ColorPicker.tsx
│       ├── RelationPicker.tsx        # Searchable (webb: dropdown, native: modal)
│       ├── MediaUpload.tsx           # Webb: drag+drop, native: expo-image-picker
│       ├── JsonEditor.tsx            # Webb: Monaco, native: readonly/textarea
│       └── MarkdownEditor.tsx
├── hooks/
│   ├── useAdminSchema.ts            # Hämta + cache:a schema för aktiv session
│   ├── useResource.ts               # CRUD-operationer
│   ├── useResourceList.ts           # Lista med TanStack Query
│   ├── useResourceForm.ts           # Formulär + ETag
│   ├── useRelation.ts              # Hämta relaterade resurser (debounced search)
│   └── useETag.ts                  # ETag-hantering
├── stores/
│   ├── sessions.ts                  # Multi-session state (Zustand + MMKV)
│   └── preferences.ts              # Tema, språk
├── lib/
│   ├── api-client.ts               # Session-aware fetch wrapper
│   ├── schema-parser.ts            # AdminSchema → UI-konfiguration
│   ├── form-validator.ts           # FieldSchema → Zod schema
│   └── platform.ts                 # Platform detection helpers
└── types/
    ├── schema.ts                    # AdminSchema, ResourceSchema, etc.
    └── session.ts                   # ApiSession

Plattformsspecifika filer

Expo väljer automatiskt rätt fil baserat på plattform:

DataTable.tsx          → Default (används om ingen specifik finns)
DataTable.web.tsx      → Används på webb
DataTable.native.tsx   → Används på iOS + Android
DataTable.ios.tsx      → Används bara på iOS
DataTable.android.tsx  → Används bara på Android

Strategi: Dela så mycket som möjligt. Bara komponenter som renderar fundamentalt annorlunda (tabell vs lista, dropdown vs bottom sheet) behöver plattformsspecifika filer.


7. Vyer och komponenter

7.1 Root Layout

// app/_layout.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { TamaguiProvider } from 'tamagui'
import { Stack } from 'expo-router'

const queryClient = new QueryClient()

export default function RootLayout() {
  return (
    <TamaguiProvider>
      <QueryClientProvider client={queryClient}>
        <Stack>
          <Stack.Screen name="index" options={{ title: 'Craft Easy Admin' }} />
          <Stack.Screen name="login" options={{ presentation: 'modal' }} />
          <Stack.Screen name="add-api" options={{ presentation: 'modal' }} />
          <Stack.Screen name="(admin)" options={{ headerShown: false }} />
        </Stack>
      </QueryClientProvider>
    </TamaguiProvider>
  )
}

7.2 Session Switcher (startsida)

// app/index.tsx
import { useSessionStore } from '../stores/sessions'

export default function HomeScreen() {
  const { sessions, activeSessionId, switchSession } = useSessionStore()

  if (sessions.length === 0) {
    return <EmptyState onAdd={() => router.push('/add-api')} />
  }

  return (
    <View style={{ flex: 1 }}>
      <Text style={styles.heading}>Your APIs</Text>

      {sessions.map(session => (
        <SessionCard
          key={session.id}
          session={session}
          isActive={session.id === activeSessionId}
          onPress={() => {
            switchSession(session.id)
            if (session.token) {
              router.push('/(admin)')
            } else {
              router.push('/login')
            }
          }}
          onLongPress={() => showSessionOptions(session)}
        />
      ))}

      <Button onPress={() => router.push('/add-api')}>
        Add API
      </Button>
    </View>
  )
}

7.3 Admin Layout

// app/(admin)/_layout.tsx
import { useAdminSchema } from '../../hooks/useAdminSchema'
import { Platform } from 'react-native'

export default function AdminLayout() {
  const { schema, isLoading } = useAdminSchema()

  if (isLoading) return <LoadingScreen />

  // Webb: sidebar layout
  // Native: drawer eller bottom tabs
  if (Platform.OS === 'web') {
    return (
      <View style={{ flexDirection: 'row', flex: 1 }}>
        <Sidebar schema={schema} />
        <View style={{ flex: 1 }}>
          <Header />
          <Slot />
        </View>
      </View>
    )
  }

  // Native: Drawer navigation
  return (
    <Drawer>
      <Drawer.Screen name="index" options={{ title: 'Dashboard' }} />
      {schema?.resources.map(resource => (
        <Drawer.Screen
          key={resource.name}
          name={`[resource]`}
          options={{ title: resource.label_plural }}
          initialParams={{ resource: resource.name }}
        />
      ))}
    </Drawer>
  )
}

7.4 ListView — plattformsanpassad

// components/list/DataTable.web.tsx — Webb-version
import { useReactTable, getCoreRowModel, flexRender } from '@tanstack/react-table'

export function DataTable({ schema, data, onSort, onPageChange }) {
  const columns = schema.list.fields.map(field => ({
    accessorKey: field,
    header: schema.fields[field].label || field,
    cell: ({ getValue }) => (
      <CellRenderer value={getValue()} fieldSchema={schema.fields[field]} />
    ),
  }))

  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
  })

  return (
    <table className="w-full">
      <thead>
        {table.getHeaderGroups().map(headerGroup => (
          <tr key={headerGroup.id}>
            {headerGroup.headers.map(header => (
              <th key={header.id} onClick={() => onSort(header.id)}>
                {flexRender(header.column.columnDef.header, header.getContext())}
              </th>
            ))}
          </tr>
        ))}
      </thead>
      <tbody>
        {table.getRowModel().rows.map(row => (
          <tr key={row.id} onClick={() => router.push(`/${schema.name}/${row.original._id}`)}>
            {row.getVisibleCells().map(cell => (
              <td key={cell.id}>
                {flexRender(cell.column.columnDef.cell, cell.getContext())}
              </td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  )
}
// components/list/DataTable.native.tsx — Native-version (kortlayout)
import { FlatList } from 'react-native'

export function DataTable({ schema, data, onEndReached }) {
  return (
    <FlatList
      data={data}
      keyExtractor={item => item._id}
      onEndReached={onEndReached}
      renderItem={({ item }) => (
        <Pressable
          onPress={() => router.push(`/${schema.name}/${item._id}`)}
          style={styles.card}
        >
          <Text style={styles.title}>{item[schema.list.fields[0]]}</Text>
          {schema.list.fields.slice(1, 4).map(field => (
            <View key={field} style={styles.row}>
              <Text style={styles.label}>{schema.fields[field].label || field}</Text>
              <CellRenderer value={item[field]} fieldSchema={schema.fields[field]} />
            </View>
          ))}
        </Pressable>
      )}
    />
  )
}

7.5 FormBuilder (universal)

// components/form/FormBuilder.tsx
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { generateZodSchema } from '../../lib/form-validator'

export function FormBuilder({ schema, data, mode, onSubmit, onCancel }) {
  const validationSchema = generateZodSchema(schema.fields)

  const { control, handleSubmit, formState: { errors } } = useForm({
    resolver: zodResolver(validationSchema),
    defaultValues: data || {},
  })

  const groups = schema.form?.groups?.length
    ? schema.form.groups
    : [{ label: 'General', fields: Object.keys(schema.fields).filter(f =>
        !schema.fields[f].readonly && !schema.fields[f].hidden
      )}]

  return (
    <ScrollView>
      {groups.map(group => (
        <FieldGroup key={group.label} label={group.label}>
          {group.fields.map(fieldName => (
            <Controller
              key={fieldName}
              name={fieldName}
              control={control}
              render={({ field }) => (
                <FieldRenderer
                  name={fieldName}
                  schema={schema.fields[fieldName]}
                  value={field.value}
                  onChange={field.onChange}
                  error={errors[fieldName]?.message}
                />
              )}
            />
          ))}
        </FieldGroup>
      ))}

      <FormActions
        mode={mode}
        onSubmit={handleSubmit(onSubmit)}
        onCancel={onCancel}
      />
    </ScrollView>
  )
}

7.6 FieldRenderer (universal med plattformsspecifika widgets)

// components/form/FieldRenderer.tsx
const WIDGET_MAP: Record<string, React.ComponentType<WidgetProps>> = {
  text: TextInput,
  textarea: TextArea,
  number: NumberInput,
  select: SelectInput,       // .web.tsx → dropdown, .native.tsx → bottom sheet
  boolean: BooleanSwitch,
  date: DatePicker,          // .web.tsx → HTML, .native.tsx → native picker
  datetime: DateTimePicker,
  email: EmailInput,
  url: UrlInput,
  color: ColorPicker,
  relation: RelationPicker,  // .web.tsx → searchable dropdown, .native.tsx → modal
  media: MediaUpload,        // .web.tsx → drag+drop, .native.tsx → image picker
  json: JsonEditor,
  markdown: MarkdownEditor,
}

export function FieldRenderer({ name, schema, value, onChange, error }: WidgetProps) {
  const Widget = WIDGET_MAP[schema.widget] || TextInput

  return (
    <View>
      <Label>{schema.label || name}</Label>
      <Widget
        value={value}
        onChange={onChange}
        schema={schema}
      />
      {schema.help_text && <HelpText>{schema.help_text}</HelpText>}
      {error && <ErrorText>{error}</ErrorText>}
    </View>
  )
}

8. Relation-hantering

// components/widgets/RelationPicker.tsx
import { useRelation } from '../../hooks/useRelation'

export function RelationPicker({ schema, value, onChange }) {
  const { relation } = schema
  const { items, search, setSearch, isLoading, selectedLabel } = useRelation(relation, value)

  // Plattformsspecifik rendering hanteras via .web.tsx / .native.tsx
  // Detta är den delade logiken
  return (
    <SearchableSelect
      value={value}
      label={selectedLabel}
      options={items}
      loading={isLoading}
      onSearch={setSearch}
      onChange={onChange}
      placeholder={`Search ${relation.label_field}...`}
    />
  )
}
// hooks/useRelation.ts
import { useQuery } from '@tanstack/react-query'
import { useApiClient } from './useApiClient'
import { useDebouncedValue } from './useDebouncedValue'

export function useRelation(config: RelationConfig, currentValue?: string) {
  const [search, setSearch] = useState('')
  const debouncedSearch = useDebouncedValue(search, 300)
  const api = useApiClient()

  // Hämta options
  const { data: items, isLoading } = useQuery({
    queryKey: ['relation', config.endpoint, debouncedSearch],
    queryFn: () => api.fetch(`${config.endpoint}?${config.search_field}__regex=${debouncedSearch}&per_page=20`),
    select: (data) => data.items.map(item => ({
      value: item[config.value_field || '_id'],
      label: item[config.label_field || 'name'],
    })),
  })

  // Hämta label för nuvarande värde
  const { data: selectedLabel } = useQuery({
    queryKey: ['relation-label', config.endpoint, currentValue],
    queryFn: () => api.fetch(`${config.endpoint}/${currentValue}`),
    select: (data) => data[config.label_field || 'name'],
    enabled: !!currentValue,
  })

  return { items: items || [], search, setSearch, isLoading, selectedLabel }
}

9. Autentisering och rättigheter

9.1 Multi-session auth

Varje session har sin egen token. Login sker per session:

// app/login.tsx
export default function LoginScreen() {
  const { activeSession, updateToken } = useSessionStore()
  const [apiCode, setApiCode] = useState('')

  const login = async () => {
    const response = await fetch(`${activeSession.url}/auth/login/api-code`, {
      method: 'POST',
      headers: { 'X-API-Key': apiCode },
    })
    const { token, expires } = await response.json()
    updateToken(activeSession.id, token, new Date(expires).getTime())
    router.replace('/(admin)')
  }

  return (
    <View>
      <Text>Login to {activeSession?.name}</Text>
      <TextInput
        value={apiCode}
        onChangeText={setApiCode}
        placeholder="API Key"
        secureTextEntry
      />
      <Button onPress={login}>Login</Button>
    </View>
  )
}

9.2 Auto-refresh per session

// lib/token-refresh.ts
export function setupTokenRefresh() {
  setInterval(() => {
    const { sessions, updateToken } = useSessionStore.getState()

    sessions.forEach(async (session) => {
      if (!session.token || !session.tokenExpiry) return

      // Refresh 5 minuter innan expiry
      const timeLeft = session.tokenExpiry - Date.now()
      if (timeLeft < 5 * 60 * 1000 && timeLeft > 0) {
        const response = await fetch(`${session.url}/auth/refresh`, {
          method: 'POST',
          headers: { 'X-API-Key': session.token },
        })
        const { token, expires } = await response.json()
        updateToken(session.id, token, new Date(expires).getTime())
      }
    })
  }, 60_000) // Kolla varje minut
}

9.3 Feature-baserad UI

const canCreate = schema.methods.includes('POST') && schema.features.create
const canEdit = schema.methods.includes('PATCH') && schema.features.edit
const canDelete = schema.methods.includes('DELETE') && schema.features.delete

// Dölj knappar baserat på rättigheter
{canCreate && <Button onPress={() => router.push(`/${resource}/create`)}>New</Button>}

10. Realtidsupplevelse och ETag

10.1 ETag-hook

// hooks/useETag.ts
export function useETag() {
  const etags = useRef(new Map<string, string>())

  const fetchWithETag = async (url: string) => {
    const response = await api.fetch(url)
    const etag = response.headers.get('ETag')
    if (etag) etags.current.set(url, etag)
    return response
  }

  const patchWithETag = async (url: string, data: any) => {
    const etag = etags.current.get(url)
    if (!etag) throw new Error('No ETag — fetch first')

    return api.fetch(url, {
      method: 'PATCH',
      body: JSON.stringify(data),
      headers: { 'If-Match': etag },
    })
  }

  return { fetchWithETag, patchWithETag }
}

10.2 Optimistic updates via TanStack Query

// hooks/useResourceForm.ts
const mutation = useMutation({
  mutationFn: (data) => patchWithETag(`/${resource}/${id}`, data),
  onMutate: async (newData) => {
    await queryClient.cancelQueries({ queryKey: [resource, id] })
    const previous = queryClient.getQueryData([resource, id])
    queryClient.setQueryData([resource, id], { ...previous, ...newData })
    return { previous }
  },
  onError: (err, newData, context) => {
    queryClient.setQueryData([resource, id], context.previous)
    if (err.status === 412) {
      showConflictDialog()
    }
  },
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: [resource, id] })
  },
})

11. Tema och anpassning

Per-session tema

Varje API kan returnera tema i admin-schemat. Appen applicerar temat för aktiv session:

// app/_layout.tsx
const activeSession = useSessionStore(s => s.activeSession())

const theme = useMemo(() => ({
  ...defaultTheme,
  primaryColor: activeSession?.theme?.primaryColor || '#2563eb',
}), [activeSession])

return (
  <TamaguiProvider theme={theme}>
    {children}
  </TamaguiProvider>
)

Dark mode

import { useColorScheme } from 'react-native'
const colorScheme = useColorScheme() // 'light' | 'dark'

Tamagui hanterar dark/light automatiskt baserat på system-inställning.


12. Plattformsspecifikt

Vad som delas vs separeras

Komponent Webb Native Delad kod
Hooks (all logik) 100%
Stores (state) 100%
Schema-parser 100%
API-klient 100%
Formulär-logik 100%
Validering (Zod) 100%
DataTable HTML table FlatList kort ~30%
SelectInput <select> / dropdown Bottom sheet ~20%
DatePicker HTML date input Native picker ~10%
MediaUpload Drag + drop expo-image-picker ~10%
Sidebar Vänster sidebar Drawer ~20%
JsonEditor Monaco Readonly/textarea ~0%

~85% av koden delas. Bara rendering-lagret skiljer sig.

Plattform Mönster
Webb (desktop) Sidebar (vänster) + content
Webb (mobil) Hamburger → drawer
Native Drawer + stack navigation

13. Testning

Stack

Verktyg Syfte
Jest + @testing-library/react-native Unit + component tests
MSW Mock API responses
Maestro E2E native tests (iOS/Android)
Playwright E2E webb tests

Teststrategi

Delad kod (hooks, stores, lib)  → Jest unit tests (90% coverage)
Webb-komponenter                → Playwright E2E
Native-komponenter              → Maestro E2E

14. Deployment

Webb

npx expo export:web
# → dist/ med statiska filer → Azure Static Web Apps
Miljö URL
Dev localhost:8081
Test admin-test.airpark.com
Prod admin.airpark.com

iOS + Android

# Bygg med EAS (Expo Application Services)
eas build --platform ios
eas build --platform android

# Publicera till stores
eas submit --platform ios
eas submit --platform android

OTA-uppdateringar (utan ny store-release)

eas update --branch production --message "Fix session switching bug"
# → Alla användare får uppdateringen nästa gång appen startar

CI/CD

# .github/workflows/deploy.yml
name: Deploy
on:
  push:
    branches: [main]

jobs:
  web:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npx expo export:web
      - uses: Azure/static-web-apps-deploy@v1
        with:
          app_location: 'dist'

  native:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: expo/expo-github-action@v8
        with:
          eas-version: latest
          token: ${{ secrets.EXPO_TOKEN }}
      - run: eas build --platform all --non-interactive
      - run: eas submit --platform all --non-interactive

15. Implementationsplan

Fas 1: Fundament + Multi-session (3 veckor)

Vecka Leverans
1 Expo-projekt, Expo Router, Zustand session store, session switcher UI
2 Schema loader, admin layout (sidebar webb, drawer native), dynamisk routing
3 API-klient med session-kontext, login per session, token refresh

Resultat: App som kan lägga till API:er, logga in, växla session.

Fas 2: CRUD (3 veckor)

Vecka Leverans
4 ListView: DataTable (webb + native), sök, pagination, sortering
5 FormBuilder, FieldRenderer, alla grundwidgets (text, number, select, boolean, date)
6 CreateView, EditView med ETag, DeleteView med bekräftelse

Resultat: Full CRUD på alla resurser, webb + native.

Fas 3: Rikt gränssnitt (2 veckor)

Vecka Leverans
7 RelationPicker, FilterBar, CellRenderer (badges, färger), optimistic updates
8 MediaUpload, dark mode, per-session tema, konflikthantering (412)

Resultat: Polerad admin med alla widgets och bra UX.

Fas 4: Distribution (2 veckor)

Vecka Leverans
9 Webb-deploy (Azure SWA), EAS Build (iOS + Android), CI/CD pipeline
10 Testning (Jest + Playwright + Maestro), App Store + Google Play submission

Resultat: Admin tillgänglig som webb, iOS-app och Android-app.

Totalt: 10 veckor


Sammanfattning

Beslut Val Motivering
Ramverk React 19 + Expo SDK 53 En kodbas → webb + iOS + Android
Routing Expo Router v4 File-based, universal, deep linking
UI Tamagui Universal komponenter (HTML + native)
State Zustand + MMKV Snabb, persistent, multi-session
Server state TanStack Query v5 Caching, optimistic updates
Formulär React Hook Form + Zod Schema-driven, best-in-class
Tabeller TanStack Table v8 Headless, samma logik webb + native
Multi-session Zustand store med session-per-API Flera API:er, inloggad överallt
Hosting webb Azure Static Web Apps Kredit-täckt
App stores Expo EAS Build + Submit iOS + Android från CI/CD
OTA Expo Updates Uppdatera utan ny store-release
Tid ~10 veckor Fundament (3v) → CRUD (3v) → Polish (2v) → Ship (2v)