diff --git a/CollabTableAndroid/README.md b/CollabTableAndroid/README.md index 8fceacc..cd36877 100644 --- a/CollabTableAndroid/README.md +++ b/CollabTableAndroid/README.md @@ -1,11 +1,11 @@ # CollabTable Android App -A collaborative list management Android application built with Jetpack Compose and Material 3. +A collaborative table management Android application built with Jetpack Compose and Material 3. ## Features -- Create and manage multiple lists -- Add custom fields to lists (name, link, price, category, etc.) +- Create and manage multiple tables +- Add custom fields to tables (name, link, price, category, etc.) - Add items with values for each field - Beautiful Material 3 design with dynamic colors - Local Room database for offline support @@ -36,7 +36,7 @@ A collaborative list management Android application built with Jetpack Compose a The app includes a Settings screen where you can configure the server URL: -1. Tap the Settings icon (⚙️) in the top bar of the Lists screen +1. Tap the Settings icon (⚙️) in the top bar of the Tables screen 2. Enter your server URL 3. Tap "Save" @@ -75,13 +75,13 @@ app/src/main/java/com/collabtable/app/ ## Usage -### Creating a List +### Creating a Table 1. Tap the + button on the main screen -2. Enter a list name +2. Enter a table name 3. Tap "Save" ### Adding Fields -1. Open a list +1. Open a table 2. Tap the + icon in the top bar 3. Enter a field name (e.g., "Name", "Price", "Category") 4. Tap "Add" @@ -92,7 +92,7 @@ app/src/main/java/com/collabtable/app/ 3. Values are saved automatically as you type ### Deleting -- Tap the delete icon next to any list, field, or item +- Tap the delete icon next to any table, field, or item - Confirm the deletion ## Synchronization diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/MainActivity.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/MainActivity.kt index 71534bd..6dd4a64 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/MainActivity.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/MainActivity.kt @@ -7,6 +7,9 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.ui.Modifier +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import com.collabtable.app.data.preferences.PreferencesManager import com.collabtable.app.ui.navigation.AppNavigation import com.collabtable.app.ui.theme.CollabTableTheme @@ -14,7 +17,18 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { - CollabTableTheme { + val prefs = PreferencesManager.getInstance(this) + val themeMode by prefs.themeMode.collectAsState() + val dynamicColor by prefs.dynamicColor.collectAsState() + val amoledDark by prefs.amoledDark.collectAsState() + + val darkTheme = when (themeMode) { + PreferencesManager.THEME_MODE_LIGHT -> false + PreferencesManager.THEME_MODE_DARK -> true + else -> androidx.compose.foundation.isSystemInDarkTheme() + } + + CollabTableTheme(darkTheme = darkTheme, dynamicColor = dynamicColor, amoledDark = amoledDark) { Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/WebSocketSyncClient.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/WebSocketSyncClient.kt new file mode 100644 index 0000000..b3b067a --- /dev/null +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/WebSocketSyncClient.kt @@ -0,0 +1,142 @@ +package com.collabtable.app.data.api + +import android.content.Context +import com.collabtable.app.data.preferences.PreferencesManager +import com.collabtable.app.utils.Logger +import com.google.gson.Gson +import com.google.gson.JsonObject +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okhttp3.logging.HttpLoggingInterceptor +import java.util.UUID +import java.util.concurrent.TimeUnit + +private data class MessageEnvelope( + val id: String? = null, + val type: String, + val payload: Any? = null +) + +object WebSocketSyncClient { + private val gson = Gson() + + private val logging = HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BASIC + } + + private val client: OkHttpClient = OkHttpClient.Builder() + .addInterceptor(logging) + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build() + + private fun buildWebSocketUrl(httpApiBaseUrl: String): String { + // Expecting something like: http://host:port/api/ + var url = httpApiBaseUrl + // Ensure trailing slash removed for manipulation + if (url.endsWith("/")) url = url.dropLast(1) + + // Replace scheme + url = when { + url.startsWith("https://") -> "wss://" + url.removePrefix("https://") + url.startsWith("http://") -> "ws://" + url.removePrefix("http://") + else -> "ws://$url" // best effort + } + + // Replace trailing /api with /api/ws + return if (url.endsWith("/api")) "$url/ws" else "$url/api/ws" + } + + suspend fun sync(context: Context, request: SyncRequest): Result = withContext(Dispatchers.IO) { + val prefs = PreferencesManager.getInstance(context) + val baseUrl = prefs.getServerUrl() + val password = prefs.getServerPassword() + val wsUrl = buildWebSocketUrl(baseUrl) + val messageId = UUID.randomUUID().toString() + + val deferred = CompletableDeferred>() + var socket: WebSocket? = null + + val httpRequestBuilder = Request.Builder().url(wsUrl) + if (!password.isNullOrBlank()) { + httpRequestBuilder.addHeader("Authorization", "Bearer $password") + } + val httpRequest = httpRequestBuilder.build() + + val listener = object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + // Send sync message when opened + val envelope = MessageEnvelope( + id = messageId, + type = "sync", + payload = request + ) + val json = gson.toJson(envelope) + webSocket.send(json) + } + + override fun onMessage(webSocket: WebSocket, text: String) { + try { + val root = gson.fromJson(text, JsonObject::class.java) + val type = root.get("type")?.asString + val id = root.get("id")?.asString + if (type == "syncResponse" && id == messageId) { + val payload = root.getAsJsonObject("payload") + val resp = gson.fromJson(payload, SyncResponse::class.java) + if (!deferred.isCompleted) { + deferred.complete(Result.success(resp)) + webSocket.close(1000, "done") + } + } else if (type == "error" && !deferred.isCompleted) { + val msg = root.getAsJsonObject("payload")?.get("message")?.asString ?: "WebSocket error" + deferred.complete(Result.failure(Exception(msg))) + webSocket.close(1002, "error") + } + } catch (e: Exception) { + if (!deferred.isCompleted) { + deferred.complete(Result.failure(e)) + } + } + } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + // 1008 indicates policy violation, used here for Unauthorized + if (!deferred.isCompleted) { + if (code == 1008) { + deferred.complete(Result.failure(Exception("Unauthorized (WS 1008)"))) + } else { + deferred.complete(Result.failure(Exception("WebSocket closing ($code): $reason"))) + } + } + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + if (!deferred.isCompleted) { + deferred.complete(Result.failure(t)) + } + } + } + + try { + socket = client.newWebSocket(httpRequest, listener) + // Wait up to 15s for response + val result = withTimeout(15_000) { deferred.await() } + return@withContext result + } catch (e: Exception) { + Logger.w("WS", "Falling back to HTTP sync: ${e.message}") + return@withContext Result.failure(e) + } finally { + // OkHttp cleans up sockets automatically when no references remain + // but we try to close if still open + socket?.close(1000, null) + } + } +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/preferences/PreferencesManager.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/preferences/PreferencesManager.kt index 9e1da2b..2d77cc7 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/preferences/PreferencesManager.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/preferences/PreferencesManager.kt @@ -13,6 +13,15 @@ class PreferencesManager(context: Context) { private val _serverUrl = MutableStateFlow(getServerUrl()) val serverUrl: StateFlow = _serverUrl.asStateFlow() + private val _themeMode = MutableStateFlow(getThemeMode()) + val themeMode: StateFlow = _themeMode.asStateFlow() + + private val _dynamicColor = MutableStateFlow(isDynamicColorEnabled()) + val dynamicColor: StateFlow = _dynamicColor.asStateFlow() + + private val _amoledDark = MutableStateFlow(isAmoledDarkEnabled()) + val amoledDark: StateFlow = _amoledDark.asStateFlow() + fun getServerUrl(): String { return prefs.getString(KEY_SERVER_URL, DEFAULT_SERVER_URL) ?: DEFAULT_SERVER_URL } @@ -64,12 +73,50 @@ class PreferencesManager(context: Context) { _serverUrl.value = getServerUrl() } + // Theme settings + fun getThemeMode(): String { + return prefs.getString(KEY_THEME_MODE, THEME_MODE_SYSTEM) ?: THEME_MODE_SYSTEM + } + + fun setThemeMode(mode: String) { + val normalized = when (mode.lowercase()) { + THEME_MODE_LIGHT, THEME_MODE_DARK, THEME_MODE_SYSTEM -> mode.lowercase() + else -> THEME_MODE_SYSTEM + } + prefs.edit().putString(KEY_THEME_MODE, normalized).apply() + _themeMode.value = normalized + } + + fun isDynamicColorEnabled(): Boolean { + return prefs.getBoolean(KEY_DYNAMIC_COLOR, true) + } + + fun setDynamicColorEnabled(enabled: Boolean) { + prefs.edit().putBoolean(KEY_DYNAMIC_COLOR, enabled).apply() + _dynamicColor.value = enabled + } + + fun isAmoledDarkEnabled(): Boolean { + return prefs.getBoolean(KEY_AMOLED_DARK, false) + } + + fun setAmoledDarkEnabled(enabled: Boolean) { + prefs.edit().putBoolean(KEY_AMOLED_DARK, enabled).apply() + _amoledDark.value = enabled + } + companion object { private const val KEY_SERVER_URL = "server_url" private const val KEY_FIRST_RUN = "first_run" private const val KEY_SERVER_PASSWORD = "server_password" private const val KEY_LAST_SYNC_TIMESTAMP = "last_sync_timestamp" + private const val KEY_THEME_MODE = "theme_mode" // system|light|dark + private const val KEY_DYNAMIC_COLOR = "dynamic_color" + private const val KEY_AMOLED_DARK = "amoled_dark" private const val DEFAULT_SERVER_URL = "http://10.0.2.2:3000/api/" + const val THEME_MODE_SYSTEM = "system" + const val THEME_MODE_LIGHT = "light" + const val THEME_MODE_DARK = "dark" @Volatile private var instance: PreferencesManager? = null diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/repository/SyncRepository.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/repository/SyncRepository.kt index 79d4132..0bf493e 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/repository/SyncRepository.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/repository/SyncRepository.kt @@ -2,6 +2,7 @@ package com.collabtable.app.data.repository import android.content.Context import com.collabtable.app.data.api.ApiClient +import com.collabtable.app.data.api.WebSocketSyncClient import com.collabtable.app.data.api.SyncRequest import com.collabtable.app.data.database.CollabTableDatabase import com.collabtable.app.utils.Logger @@ -55,82 +56,89 @@ class SyncRepository(context: Context) { itemValues = localValues ) - val response = api.sync(syncRequest) - - if (response.isSuccessful) { - val syncResponse = response.body()!! - - // Only log receive when there are actual server changes - val inTotal = syncResponse.lists.size + syncResponse.fields.size + syncResponse.items.size + syncResponse.itemValues.size - if (inTotal > 0) { - Logger.i( - "Sync", - "[IN] Received changes (lists=${syncResponse.lists.size}, fields=${syncResponse.fields.size}, items=${syncResponse.items.size}, values=${syncResponse.itemValues.size})" - ) - } - - // Apply server changes to local database atomically in correct order - database.withTransaction { - // 1) Upsert lists first - database.listDao().insertLists(syncResponse.lists) - - // Build set of existing list ids to guard child inserts - val existingListIds = database.listDao().getAllListIds().toSet() - - // 2) Filter and upsert fields whose parent list exists - val filteredFields = syncResponse.fields.filter { f -> f.listId in existingListIds } - if (filteredFields.size != syncResponse.fields.size) { - val dropped = syncResponse.fields.size - filteredFields.size - Logger.w("Sync", "Dropping $dropped field(s) with missing parent list to avoid FK errors") - } - if (filteredFields.isNotEmpty()) { - database.fieldDao().insertFields(filteredFields) - } - - // 3) Filter and upsert items whose parent list exists - val filteredItems = syncResponse.items.filter { i -> i.listId in existingListIds } - if (filteredItems.size != syncResponse.items.size) { - val dropped = syncResponse.items.size - filteredItems.size - Logger.w("Sync", "Dropping $dropped item(s) with missing parent list to avoid FK errors") - } - if (filteredItems.isNotEmpty()) { - database.itemDao().insertItems(filteredItems) - } - - // 4) Filter item values to only those whose parents (item and field) exist locally - val existingItemIds = database.itemDao().getAllItemIds().toSet() - val existingFieldIds = database.fieldDao().getAllFieldIds().toSet() - val filteredValues = syncResponse.itemValues.filter { v -> - v.itemId in existingItemIds && v.fieldId in existingFieldIds - } - if (filteredValues.size != syncResponse.itemValues.size) { - val dropped = syncResponse.itemValues.size - filteredValues.size - Logger.w("Sync", "Dropping $dropped item value(s) with missing parents to avoid FK errors") - } - if (filteredValues.isNotEmpty()) { - database.itemValueDao().insertValues(filteredValues) - } - } - - // Update last sync timestamp - setLastSyncTimestamp(syncResponse.serverTimestamp) - - if (isInitialSync) { - Logger.i("Sync", "[SYNC] Initial sync completed") - } - - return@withContext Result.success(Unit) + // Try WebSocket first; on failure, fall back to HTTP + val wsResult = WebSocketSyncClient.sync(appContext, syncRequest) + val syncResponse = if (wsResult.isSuccess) { + wsResult.getOrThrow() } else { - if (response.code() == 401) { - // Unauthorized: likely bad/missing password. Reset sync baseline and surface a clear error. - Logger.e("Sync", "[ERROR] Unauthorized (401). Check server password in Settings.") - setLastSyncTimestamp(0) - return@withContext Result.failure(Exception("Unauthorized (401). Please verify server password.")) + val response = api.sync(syncRequest) + if (!response.isSuccessful) { + if (response.code() == 401) { + // Unauthorized: likely bad/missing password. Reset sync baseline and surface a clear error. + Logger.e("Sync", "[ERROR] Unauthorized (401). Check server password in Settings.") + setLastSyncTimestamp(0) + return@withContext Result.failure(Exception("Unauthorized (401). Please verify server password.")) + } + Logger.e("Sync", "[ERROR] Sync failed: HTTP ${response.code()}") + return@withContext Result.failure(Exception("Sync failed: ${response.code()}")) } - Logger.e("Sync", "[ERROR] Sync failed: HTTP ${response.code()}") - return@withContext Result.failure(Exception("Sync failed: ${response.code()}")) + response.body()!! } + // Only log receive when there are actual server changes + val inTotal = syncResponse.lists.size + syncResponse.fields.size + syncResponse.items.size + syncResponse.itemValues.size + if (inTotal > 0) { + Logger.i( + "Sync", + "[IN] Received changes (lists=${syncResponse.lists.size}, fields=${syncResponse.fields.size}, items=${syncResponse.items.size}, values=${syncResponse.itemValues.size})" + ) + } + + // Apply server changes to local database atomically in correct order + database.withTransaction { + // 1) Upsert lists first + database.listDao().insertLists(syncResponse.lists) + + // Build set of existing list ids to guard child inserts + val existingListIds = database.listDao().getAllListIds().toSet() + + // 2) Filter and upsert fields whose parent list exists + val filteredFields = syncResponse.fields.filter { f -> f.listId in existingListIds } + if (filteredFields.size != syncResponse.fields.size) { + val dropped = syncResponse.fields.size - filteredFields.size + Logger.w("Sync", "Dropping $dropped field(s) with missing parent list to avoid FK errors") + } + if (filteredFields.isNotEmpty()) { + database.fieldDao().insertFields(filteredFields) + } + + // 3) Filter and upsert items whose parent list exists + val filteredItems = syncResponse.items.filter { i -> i.listId in existingListIds } + if (filteredItems.size != syncResponse.items.size) { + val dropped = syncResponse.items.size - filteredItems.size + Logger.w("Sync", "Dropping $dropped item(s) with missing parent list to avoid FK errors") + } + if (filteredItems.isNotEmpty()) { + database.itemDao().insertItems(filteredItems) + } + + // 4) Filter item values to only those whose parents (item and field) exist locally + val existingItemIds = database.itemDao().getAllItemIds().toSet() + val existingFieldIds = database.fieldDao().getAllFieldIds().toSet() + val filteredValues = syncResponse.itemValues.filter { v -> + v.itemId in existingItemIds && v.fieldId in existingFieldIds + } + if (filteredValues.size != syncResponse.itemValues.size) { + val dropped = syncResponse.itemValues.size - filteredValues.size + Logger.w("Sync", "Dropping $dropped item value(s) with missing parents to avoid FK errors") + } + if (filteredValues.isNotEmpty()) { + database.itemValueDao().insertValues(filteredValues) + } + } + + // Update last sync timestamp + setLastSyncTimestamp(syncResponse.serverTimestamp) + + if (isInitialSync) { + Logger.i("Sync", "[SYNC] Initial sync completed") + } + + return@withContext Result.success(Unit) } catch (e: Exception) { + // If WS indicated unauthorized, align behavior with HTTP path + if (e.message?.contains("Unauthorized") == true) { + setLastSyncTimestamp(0) + } Logger.e("Sync", "[ERROR] Sync error: ${e.message}") return@withContext Result.failure(e) } diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsScreen.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsScreen.kt index 765a514..5cceda5 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsScreen.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsScreen.kt @@ -103,7 +103,7 @@ fun ListsScreen( color = MaterialTheme.colorScheme.onSurfaceVariant ) Text( - text = "Tap + to create your first list", + text = "Tap + to create your first table", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -268,7 +268,7 @@ private fun RenameListDialog( AlertDialog( onDismissRequest = onDismiss, - title = { Text("Rename List") }, + title = { Text("Rename Table") }, text = { OutlinedTextField( value = listName, diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsViewModel.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsViewModel.kt index f749ee1..6a2ff82 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsViewModel.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsViewModel.kt @@ -54,13 +54,13 @@ class ListsViewModel( private suspend fun performSync() { val result = syncRepository.performSync() result.onFailure { error -> - Logger.e("Lists", "❌ Sync failed: ${error.message}") + Logger.e("Tables", "❌ Sync failed: ${error.message}") } } fun createList(name: String) { viewModelScope.launch { - Logger.i("Lists", "➕ Creating list: \"$name\"") + Logger.i("Tables", "➕ Creating table: \"$name\"") val timestamp = System.currentTimeMillis() val newList = CollabList( id = UUID.randomUUID().toString(), @@ -78,7 +78,7 @@ class ListsViewModel( viewModelScope.launch { val list = database.listDao().getListById(listId) if (list != null && newName.isNotBlank()) { - Logger.i("Lists", "✏️ Renaming: \"${list.name}\" → \"$newName\"") + Logger.i("Tables", "✏️ Renaming table: \"${list.name}\" → \"$newName\"") database.listDao().updateList( list.copy( name = newName.trim(), @@ -95,7 +95,7 @@ class ListsViewModel( viewModelScope.launch { val list = database.listDao().getListById(listId) if (list != null) { - Logger.i("Lists", "🗑️ Deleting list: \"${list.name}\"") + Logger.i("Tables", "🗑️ Deleting table: \"${list.name}\"") database.listDao().softDeleteList(listId, System.currentTimeMillis()) // Sync immediately after deleting performSync() @@ -105,7 +105,7 @@ class ListsViewModel( fun manualSync() { viewModelScope.launch { - Logger.i("Lists", "🔄 Manual sync requested") + Logger.i("Tables", "🔄 Manual sync requested") _isLoading.value = true performSync() _isLoading.value = false diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ServerSetupScreen.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ServerSetupScreen.kt index ab14968..58a1554 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ServerSetupScreen.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ServerSetupScreen.kt @@ -27,7 +27,7 @@ fun ServerSetupScreen( val context = LocalContext.current val preferencesManager = remember { PreferencesManager.getInstance(context) } val viewModel = remember { ServerSetupViewModel(preferencesManager) } - + var serverUrl by remember { mutableStateOf("") } var serverPassword by remember { mutableStateOf("") } var passwordVisible by remember { mutableStateOf(false) } @@ -37,7 +37,6 @@ fun ServerSetupScreen( LaunchedEffect(validationResult) { if (validationResult == true) { - // Validation successful, navigate to main screen onSetupComplete() } } @@ -57,34 +56,16 @@ fun ServerSetupScreen( modifier = Modifier .fillMaxSize() .padding(padding) - .padding(24.dp), + .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) + verticalArrangement = Arrangement.spacedBy(12.dp) ) { - Spacer(modifier = Modifier.height(32.dp)) - - // Welcome text - Text( - text = "Welcome to CollabTable!", - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.primary, - textAlign = TextAlign.Center - ) - - Text( - text = "Please configure your server connection to get started.", - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - Spacer(modifier = Modifier.height(16.dp)) - - // Server URL input + Spacer(modifier = Modifier.height(4.dp)) + OutlinedTextField( value = serverUrl, onValueChange = { serverUrl = it }, - label = { Text("Server URL") }, + label = { Text("Server Hostname") }, placeholder = { Text("example.com or 10.0.2.2:3000") }, modifier = Modifier.fillMaxWidth(), enabled = !isValidating, @@ -95,7 +76,7 @@ fun ServerSetupScreen( text = validationError ?: "", color = MaterialTheme.colorScheme.error ) - else -> Text("Enter hostname with optional port (default: 80/443)") + else -> Text("Hostname only. Port optional (80/443 default). No http://, no /api/") } }, trailingIcon = { @@ -117,8 +98,7 @@ fun ServerSetupScreen( } } ) - - // Server Password input + OutlinedTextField( value = serverPassword, onValueChange = { serverPassword = it }, @@ -136,16 +116,11 @@ fun ServerSetupScreen( ) } }, - supportingText = { - Text("Password required for server authentication") - } + supportingText = { Text("Server password for authentication") } ) - - // Validate and continue button + Button( - onClick = { - viewModel.validateAndSaveServerUrl(serverUrl.trim(), serverPassword.trim()) - }, + onClick = { viewModel.validateAndSaveServerUrl(serverUrl.trim(), serverPassword.trim()) }, modifier = Modifier.fillMaxWidth(), enabled = serverUrl.isNotBlank() && serverPassword.isNotBlank() && !isValidating ) { @@ -158,66 +133,12 @@ fun ServerSetupScreen( Spacer(modifier = Modifier.width(8.dp)) Text("Validating...") } else { - Text("Validate and Continue") + Text("Connect") } } - - Spacer(modifier = Modifier.height(8.dp)) - - // Tips card - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer - ) - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - text = "Connection Tips:", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSecondaryContainer - ) - Text( - text = "• For Android Emulator: 10.0.2.2:3000", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSecondaryContainer - ) - Text( - text = "• For Physical Device: YOUR_IP:3000", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSecondaryContainer - ) - Text( - text = "• For domains: example.com (uses port 80/443)", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSecondaryContainer - ) - Text( - text = "• Port is optional, defaults to 80 (HTTP) or 443 (HTTPS)", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSecondaryContainer - ) - Text( - text = "• Just enter hostname:port, no need for http:// or /api/", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSecondaryContainer - ) - Text( - text = "• Make sure the server is running and accessible", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSecondaryContainer - ) - } - } - - Spacer(modifier = Modifier.weight(1f)) - - // Note about changing later + Text( - text = "You can change this URL later in Settings", + text = "Tip: Emulator host 10.0.2.2:3000 · Physical device uses your PC IP", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/SettingsScreen.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/SettingsScreen.kt index f93d821..b520652 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/SettingsScreen.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/SettingsScreen.kt @@ -15,6 +15,12 @@ import com.collabtable.app.data.api.ApiClient import com.collabtable.app.data.preferences.PreferencesManager import com.collabtable.app.data.repository.SyncRepository import com.collabtable.app.utils.Logger +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material.icons.filled.DarkMode +import androidx.compose.material.icons.filled.LightMode +import androidx.compose.material.icons.filled.SettingsBrightness +import androidx.compose.ui.Alignment import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -31,6 +37,10 @@ fun SettingsScreen( val coroutineScope = rememberCoroutineScope() val serverUrl = preferencesManager.getServerUrl() + val themeMode by preferencesManager.themeMode.collectAsState() + val dynamicColor by preferencesManager.dynamicColor.collectAsState() + val amoledDark by preferencesManager.amoledDark.collectAsState() + val displayUrl = remember(serverUrl) { formatServerUrlForDisplay(serverUrl) } var showLeaveDialog by remember { mutableStateOf(false) } var isLeaving by remember { mutableStateOf(false) } @@ -69,23 +79,87 @@ fun SettingsScreen( ) ) { Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) ) { Text( - text = "Connected to:", - style = MaterialTheme.typography.labelMedium, + text = "Connected to", + style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) Text( - text = serverUrl, - style = MaterialTheme.typography.bodyLarge, + text = displayUrl, + style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface ) } } Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Appearance", + style = MaterialTheme.typography.titleMedium + ) + + // Theme mode selector + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + FilterChip( + selected = themeMode == PreferencesManager.THEME_MODE_SYSTEM, + onClick = { preferencesManager.setThemeMode(PreferencesManager.THEME_MODE_SYSTEM) }, + label = { Text("System") }, + leadingIcon = { Icon(Icons.Default.SettingsBrightness, contentDescription = null) } + ) + FilterChip( + selected = themeMode == PreferencesManager.THEME_MODE_LIGHT, + onClick = { preferencesManager.setThemeMode(PreferencesManager.THEME_MODE_LIGHT) }, + label = { Text("Light") }, + leadingIcon = { Icon(Icons.Default.LightMode, contentDescription = null) } + ) + FilterChip( + selected = themeMode == PreferencesManager.THEME_MODE_DARK, + onClick = { preferencesManager.setThemeMode(PreferencesManager.THEME_MODE_DARK) }, + label = { Text("Dark") }, + leadingIcon = { Icon(Icons.Default.DarkMode, contentDescription = null) } + ) + } + + // Dynamic color toggle + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text("Use Material 3 colors") + Text( + text = "Dynamic colors on supported devices", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch(checked = dynamicColor, onCheckedChange = { preferencesManager.setDynamicColorEnabled(it) }) + } + + // AMOLED dark toggle + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text("AMOLED dark") + Text( + text = "Pure black background in dark mode", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch(checked = amoledDark, onCheckedChange = { preferencesManager.setAmoledDarkEnabled(it) }) + } Button( onClick = { showLeaveDialog = true }, @@ -116,31 +190,31 @@ fun SettingsScreen( ) ) { Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) ) { Text( - text = "About Leave Server:", - style = MaterialTheme.typography.titleSmall, + text = "Leaving removes local data", + style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSecondaryContainer ) Text( - text = "• Disconnects from the current server", + text = "Disconnects and clears local database", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSecondaryContainer ) Text( - text = "• Clears stored server URL and password", + text = "Clears stored server URL and password", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSecondaryContainer ) Text( - text = "• Deletes all local data (lists, fields, items)", + text = "Deletes all local data (tables, fields, items)", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSecondaryContainer ) Text( - text = "• Returns to server setup screen", + text = "Returns to server setup screen", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSecondaryContainer ) @@ -211,3 +285,25 @@ fun SettingsScreen( } } } + +private fun formatServerUrlForDisplay(raw: String): String { + var s = raw.trim() + if (s.isEmpty()) return "" + // Remove scheme + s = s.replace(Regex("^https?://", RegexOption.IGNORE_CASE), "") + // Remove trailing /api or /api/ + s = s.replace(Regex("/api/?$", RegexOption.IGNORE_CASE), "") + // Trim trailing / + s = s.trimEnd('/') + // Split authority and path (in case any path remains) + val firstSlash = s.indexOf('/') + val authority = if (firstSlash >= 0) s.substring(0, firstSlash) else s + val rest = if (firstSlash >= 0) s.substring(firstSlash) else "" + // Remove default ports from authority + val authorityStripped = when { + authority.endsWith(":80") -> authority.removeSuffix(":80") + authority.endsWith(":443") -> authority.removeSuffix(":443") + else -> authority + } + return authorityStripped + rest +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/theme/Theme.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/theme/Theme.kt index 18ac0b8..a7e779d 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/theme/Theme.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/theme/Theme.kt @@ -10,6 +10,7 @@ import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView @@ -31,9 +32,10 @@ private val LightColorScheme = lightColorScheme( fun CollabTableTheme( darkTheme: Boolean = isSystemInDarkTheme(), dynamicColor: Boolean = true, + amoledDark: Boolean = false, content: @Composable () -> Unit ) { - val colorScheme = when { + var colorScheme = when { dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { val context = LocalContext.current if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) @@ -41,6 +43,14 @@ fun CollabTableTheme( darkTheme -> DarkColorScheme else -> LightColorScheme } + + if (darkTheme && amoledDark) { + colorScheme = colorScheme.copy( + background = Color.Black, + surface = Color.Black, + surfaceVariant = Color.Black + ) + } val view = LocalView.current if (!view.isInEditMode) { SideEffect { diff --git a/CollabTableAndroid/app/src/main/res/values/strings.xml b/CollabTableAndroid/app/src/main/res/values/strings.xml index 990cc65..e8c524a 100644 --- a/CollabTableAndroid/app/src/main/res/values/strings.xml +++ b/CollabTableAndroid/app/src/main/res/values/strings.xml @@ -1,8 +1,8 @@ CollabTable - Lists - Create List - List Name + Tables + Create Table + Table Name Add Field Field Name Add Item @@ -11,7 +11,7 @@ Save Cancel Edit - No lists yet. Create one to get started! + No tables yet. Create one to get started! No items yet Search Settings @@ -19,7 +19,7 @@ Sync Syncing… Sync error - Delete List + Delete Table Delete Field Delete Item Are you sure you want to delete this? diff --git a/CollabTableAndroid/build-output.txt b/CollabTableAndroid/build-output.txt index 74e70f2..961fc6c 100644 Binary files a/CollabTableAndroid/build-output.txt and b/CollabTableAndroid/build-output.txt differ diff --git a/CollabTableServer/.env.example b/CollabTableServer/.env.example index f9a8de1..760cd24 100644 --- a/CollabTableServer/.env.example +++ b/CollabTableServer/.env.example @@ -1,4 +1,35 @@ +################################################################# +# Server +################################################################# PORT=3000 -DB_PATH=/data/collabtable.db NODE_ENV=production SERVER_PASSWORD=your_secure_password_here + +################################################################# +# Database backend selection +# - sqlite (default if not set) +# - postgres +################################################################# +# Choose the DB client; if omitted or not set to "postgres", SQLite is used +DB_CLIENT=sqlite + +################################################################# +# SQLite configuration (used when DB_CLIENT=sqlite) +################################################################# +# Path to the SQLite database file. Default in code: ./data/collabtable.db +# For Docker, you may prefer a volume path like /data/collabtable.db +DB_PATH=/data/collabtable.db + +################################################################# +# PostgreSQL configuration (used when DB_CLIENT=postgres) +# You can either provide a single DATABASE_URL or the PG* variables below +################################################################# +# Example URL: postgres://user:password@localhost:5432/collabtable +DATABASE_URL= + +# If DATABASE_URL is not set, these individual settings are used: +PGHOST=localhost +PGPORT=5432 +PGUSER=postgres +PGPASSWORD= +PGDATABASE=collabtable diff --git a/CollabTableServer/package-lock.json b/CollabTableServer/package-lock.json index 55dc1a9..20956ef 100644 --- a/CollabTableServer/package-lock.json +++ b/CollabTableServer/package-lock.json @@ -12,13 +12,17 @@ "better-sqlite3": "^11.0.0", "cors": "^2.8.5", "dotenv": "^16.3.1", - "express": "^4.18.2" + "express": "^4.18.2", + "pg": "^8.11.3", + "ws": "^8.16.0" }, "devDependencies": { "@types/better-sqlite3": "^7.6.11", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/node": "^20.10.6", + "@types/pg": "^8.10.2", + "@types/ws": "^8.5.10", "ts-node-dev": "^2.0.0", "typescript": "^5.3.3" } @@ -183,6 +187,18 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/pg": { + "version": "8.15.5", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.5.tgz", + "integrity": "sha512-LF7lF6zWEKxuT3/OR8wAZGzkg4ENGXFNyiV/JeOt9z5B+0ZVwbql9McqX5c/WStFq1GaGso7H1AzP/qSzmlCKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -244,6 +260,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -1418,6 +1444,95 @@ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "license": "MIT" }, + "node_modules/pg": { + "version": "8.16.3", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", + "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.9.1", + "pg-pool": "^3.10.1", + "pg-protocol": "^1.10.3", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.2.7" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz", + "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz", + "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz", + "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz", + "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", @@ -1431,6 +1546,45 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -1832,6 +1986,15 @@ "source-map": "^0.6.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -2126,11 +2289,31 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4" diff --git a/CollabTableServer/package.json b/CollabTableServer/package.json index 934222d..dc18513 100644 --- a/CollabTableServer/package.json +++ b/CollabTableServer/package.json @@ -16,13 +16,17 @@ "express": "^4.18.2", "cors": "^2.8.5", "better-sqlite3": "^11.0.0", - "dotenv": "^16.3.1" + "dotenv": "^16.3.1", + "ws": "^8.16.0", + "pg": "^8.11.3" }, "devDependencies": { "@types/express": "^4.17.21", "@types/cors": "^2.8.17", "@types/node": "^20.10.6", "@types/better-sqlite3": "^7.6.11", + "@types/ws": "^8.5.10", + "@types/pg": "^8.10.2", "typescript": "^5.3.3", "ts-node-dev": "^2.0.0" } diff --git a/CollabTableServer/src/db.ts b/CollabTableServer/src/db.ts new file mode 100644 index 0000000..550781d --- /dev/null +++ b/CollabTableServer/src/db.ts @@ -0,0 +1,266 @@ +import dotenv from 'dotenv'; +import BetterSqlite3, { Database as SqliteDB } from 'better-sqlite3'; +import { Pool, PoolClient, QueryResult } from 'pg'; +import path from 'path'; +import fs from 'fs'; + +dotenv.config(); + +type Param = any; + +type ExecResult = { changes: number }; + +type TxClient = { + query: (text: string, params?: any[]) => Promise>; +}; + +interface DBAdapter { + queryAll(sql: string, params?: Param[]): Promise; + queryOne(sql: string, params?: Param[]): Promise; + execute(sql: string, params?: Param[]): Promise; + transaction(fn: (tx: DBAdapter) => Promise): Promise; + initialize(): Promise; +} + +function ensureDataDir(dbPath: string) { + const dir = path.dirname(dbPath); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); +} + +function replaceQuotedIdentifiers(sql: string) { + // Convert backticks to double quotes for portability + return sql.replace(/`([^`]+)`/g, '"$1"'); +} + +function convertQMarksToPg(sql: string) { + // Replace each ? with $1, $2, ... + let idx = 0; + return sql.replace(/\?/g, () => `$${++idx}`); +} + +// ---------- SQLite Adapter (wrapped async) ---------- +class SqliteAdapter implements DBAdapter { + private db: SqliteDB; + + constructor() { + const DB_PATH = process.env.DB_PATH || './data/collabtable.db'; + ensureDataDir(DB_PATH); + this.db = new BetterSqlite3(DB_PATH); + this.db.pragma('foreign_keys = ON'); + } + + async initialize(): Promise { + // Create tables and indexes + this.db.exec(` + CREATE TABLE IF NOT EXISTS lists ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL, + isDeleted INTEGER DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS fields ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + fieldType TEXT NOT NULL, + fieldOptions TEXT, + listId TEXT NOT NULL, + "order" INTEGER NOT NULL, + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL, + isDeleted INTEGER DEFAULT 0, + FOREIGN KEY (listId) REFERENCES lists(id) + ); + + CREATE TABLE IF NOT EXISTS items ( + id TEXT PRIMARY KEY, + listId TEXT NOT NULL, + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL, + isDeleted INTEGER DEFAULT 0, + FOREIGN KEY (listId) REFERENCES lists(id) + ); + + CREATE TABLE IF NOT EXISTS item_values ( + id TEXT PRIMARY KEY, + itemId TEXT NOT NULL, + fieldId TEXT NOT NULL, + value TEXT, + updatedAt INTEGER NOT NULL, + FOREIGN KEY (itemId) REFERENCES items(id), + FOREIGN KEY (fieldId) REFERENCES fields(id) + ); + + CREATE INDEX IF NOT EXISTS idx_fields_listId ON fields(listId); + CREATE INDEX IF NOT EXISTS idx_items_listId ON items(listId); + CREATE INDEX IF NOT EXISTS idx_item_values_itemId ON item_values(itemId); + CREATE INDEX IF NOT EXISTS idx_item_values_fieldId ON item_values(fieldId); + `); + } + + async queryAll(sql: string, params: Param[] = []): Promise { + const stmt = this.db.prepare(replaceQuotedIdentifiers(sql)); + return stmt.all(...params); + } + + async queryOne(sql: string, params: Param[] = []): Promise { + const stmt = this.db.prepare(replaceQuotedIdentifiers(sql)); + return stmt.get(...params); + } + + async execute(sql: string, params: Param[] = []): Promise { + const stmt = this.db.prepare(replaceQuotedIdentifiers(sql)); + const res = stmt.run(...params); + return { changes: res.changes || 0 }; + } + + async transaction(fn: (tx: DBAdapter) => Promise): Promise { + const tx = this.db.transaction((innerFn: (tx: DBAdapter) => Promise) => { + // We can reuse the same adapter since better-sqlite3 is transactional per connection + return innerFn(this); + }); + return tx(fn); + } +} + +// ---------- Postgres Adapter ---------- +class PostgresAdapter implements DBAdapter { + private pool: Pool; + + constructor() { + const connectionString = process.env.DATABASE_URL; + if (connectionString) { + this.pool = new Pool({ connectionString }); + } else { + this.pool = new Pool({ + host: process.env.PGHOST || 'localhost', + port: parseInt(process.env.PGPORT || '5432', 10), + user: process.env.PGUSER || 'postgres', + password: process.env.PGPASSWORD || '', + database: process.env.PGDATABASE || 'collabtable' + }); + } + } + + async initialize(): Promise { + // Create tables if not exist, keeping schema aligned with sqlite (isDeleted INTEGER 0/1) + const client = await this.pool.connect(); + try { + await client.query('BEGIN'); + await client.query(` + CREATE TABLE IF NOT EXISTS lists ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + createdAt BIGINT NOT NULL, + updatedAt BIGINT NOT NULL, + isDeleted INTEGER DEFAULT 0 + ); + `); + await client.query(` + CREATE TABLE IF NOT EXISTS fields ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + fieldType TEXT NOT NULL, + fieldOptions TEXT, + listId TEXT NOT NULL, + "order" INTEGER NOT NULL, + createdAt BIGINT NOT NULL, + updatedAt BIGINT NOT NULL, + isDeleted INTEGER DEFAULT 0, + FOREIGN KEY (listId) REFERENCES lists(id) + ); + `); + await client.query(` + CREATE TABLE IF NOT EXISTS items ( + id TEXT PRIMARY KEY, + listId TEXT NOT NULL, + createdAt BIGINT NOT NULL, + updatedAt BIGINT NOT NULL, + isDeleted INTEGER DEFAULT 0, + FOREIGN KEY (listId) REFERENCES lists(id) + ); + `); + await client.query(` + CREATE TABLE IF NOT EXISTS item_values ( + id TEXT PRIMARY KEY, + itemId TEXT NOT NULL, + fieldId TEXT NOT NULL, + value TEXT, + updatedAt BIGINT NOT NULL, + FOREIGN KEY (itemId) REFERENCES items(id), + FOREIGN KEY (fieldId) REFERENCES fields(id) + ); + `); + await client.query(`CREATE INDEX IF NOT EXISTS idx_fields_listId ON fields(listId);`); + await client.query(`CREATE INDEX IF NOT EXISTS idx_items_listId ON items(listId);`); + await client.query(`CREATE INDEX IF NOT EXISTS idx_item_values_itemId ON item_values(itemId);`); + await client.query(`CREATE INDEX IF NOT EXISTS idx_item_values_fieldId ON item_values(fieldId);`); + await client.query('COMMIT'); + } catch (e) { + await client.query('ROLLBACK'); + throw e; + } finally { + client.release(); + } + } + + private async runQuery(sql: string, params: Param[] = [], client?: PoolClient): Promise> { + // Replace backticks and convert ? to $1..$n + const text = convertQMarksToPg(replaceQuotedIdentifiers(sql)); + const runner = client || this.pool; + // @ts-ignore - Pool and PoolClient share query signature + return runner.query(text, params); + } + + async queryAll(sql: string, params: Param[] = []): Promise { + const res = await this.runQuery(sql, params); + return res.rows; + } + + async queryOne(sql: string, params: Param[] = []): Promise { + const res = await this.runQuery(sql, params); + return res.rows[0]; + } + + async execute(sql: string, params: Param[] = []): Promise { + const res = await this.runQuery(sql, params); + return { changes: res.rowCount || 0 }; + } + + async transaction(fn: (tx: DBAdapter) => Promise): Promise { + const client = await this.pool.connect(); + try { + await client.query('BEGIN'); + const txAdapter: DBAdapter = { + initialize: async () => {}, + queryAll: (sql, params) => this.runQuery(sql, params, client).then(r => r.rows), + queryOne: (sql, params) => this.runQuery(sql, params, client).then(r => r.rows[0]), + execute: (sql, params) => this.runQuery(sql, params, client).then(r => ({ changes: r.rowCount || 0 })), + transaction: async (inner) => { + // Nested transactions are treated as flat for simplicity + return inner(txAdapter); + } + } as DBAdapter; + const result = await fn(txAdapter); + await client.query('COMMIT'); + return result; + } catch (e) { + await client.query('ROLLBACK'); + throw e; + } finally { + client.release(); + } + } +} + +const clientType = (process.env.DB_CLIENT || process.env.DB_TYPE || 'sqlite').toLowerCase(); +export const dbAdapter: DBAdapter = clientType === 'postgres' || clientType === 'postgresql' + ? new PostgresAdapter() + : new SqliteAdapter(); + +export async function initializeDatabase() { + await dbAdapter.initialize(); +} + +export type { DBAdapter }; diff --git a/CollabTableServer/src/index.ts b/CollabTableServer/src/index.ts index efc42d8..bc5b6e1 100644 --- a/CollabTableServer/src/index.ts +++ b/CollabTableServer/src/index.ts @@ -1,13 +1,15 @@ import express from 'express'; import cors from 'cors'; import dotenv from 'dotenv'; -import { initializeDatabase } from './database'; +import { initializeDatabase } from './db'; import { authenticatePassword } from './middleware/auth'; import listRoutes from './routes/listRoutes'; import fieldRoutes from './routes/fieldRoutes'; import itemRoutes from './routes/itemRoutes'; import syncRoutes from './routes/syncRoutes'; import webRoutes from './routes/webRoutes'; +import { createServer } from 'http'; +import { initWebSocket } from './ws'; dotenv.config(); @@ -36,23 +38,33 @@ app.use('/api', syncRoutes); app.use('/', webRoutes); // Initialize database and start server -try { - initializeDatabase(); - console.log('Database synchronized successfully'); - console.log('Database models loaded:'); - console.log('- Lists'); - console.log('- Fields'); - console.log('- Items'); - console.log('- ItemValues'); - - app.listen(PORT, () => { - console.log(`Server is running on port ${PORT}`); - console.log(`Environment: ${process.env.NODE_ENV || 'development'}`); - console.log(`Database: ${process.env.DB_PATH || './data/collabtable.db'}`); - }); -} catch (error) { - console.error('Database initialization error:', error); - process.exit(1); -} +(async () => { + try { + await initializeDatabase(); + console.log('Database synchronized successfully'); + console.log('Database models loaded:'); + console.log('- Lists'); + console.log('- Fields'); + console.log('- Items'); + console.log('- ItemValues'); + + const server = createServer(app); + initWebSocket(server); + server.listen(PORT, () => { + console.log(`Server is running on port ${PORT}`); + console.log(`Environment: ${process.env.NODE_ENV || 'development'}`); + const dbClient = (process.env.DB_CLIENT || process.env.DB_TYPE || 'sqlite').toLowerCase(); + if (dbClient === 'postgres' || dbClient === 'postgresql') { + const source = process.env.DATABASE_URL ? 'DATABASE_URL' : 'PGHOST/PGDATABASE'; + console.log(`Database: PostgreSQL (${source})`); + } else { + console.log(`Database: ${process.env.DB_PATH || './data/collabtable.db'}`); + } + }); + } catch (error) { + console.error('Database initialization error:', error); + process.exit(1); + } +})(); export default app; diff --git a/CollabTableServer/src/routes/fieldRoutes.ts b/CollabTableServer/src/routes/fieldRoutes.ts index bd9ded4..330216b 100644 --- a/CollabTableServer/src/routes/fieldRoutes.ts +++ b/CollabTableServer/src/routes/fieldRoutes.ts @@ -1,12 +1,12 @@ import { Router, Request, Response } from 'express'; -import { db } from '../database'; +import { dbAdapter } from '../db'; const router = Router(); // Get fields for a list router.get('/list/:listId', async (req: Request, res: Response) => { try { - const fields = db.prepare('SELECT * FROM fields WHERE listId = ? AND isDeleted = 0 ORDER BY `order` ASC').all(req.params.listId); + const fields = await dbAdapter.queryAll('SELECT * FROM fields WHERE listId = ? AND isDeleted = 0 ORDER BY "order" ASC', [req.params.listId]); const formattedFields = (fields as any[]).map(field => ({ ...field, isDeleted: !!field.isDeleted })); res.json(formattedFields); } catch (error) { @@ -18,12 +18,11 @@ router.get('/list/:listId', async (req: Request, res: Response) => { router.post('/', async (req: Request, res: Response) => { try { const { id, name, fieldType, fieldOptions, listId, order, createdAt, updatedAt, isDeleted } = req.body; - db.prepare(` - INSERT INTO fields (id, name, fieldType, fieldOptions, listId, \`order\`, createdAt, updatedAt, isDeleted) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run(id, name, fieldType, fieldOptions, listId, order, createdAt, updatedAt, isDeleted ? 1 : 0); - - const field = db.prepare('SELECT * FROM fields WHERE id = ?').get(id); + await dbAdapter.execute( + 'INSERT INTO fields (id, name, fieldType, fieldOptions, listId, "order", createdAt, updatedAt, isDeleted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', + [id, name, fieldType, fieldOptions, listId, order, createdAt, updatedAt, isDeleted ? 1 : 0] + ); + const field = await dbAdapter.queryOne('SELECT * FROM fields WHERE id = ?', [id]); res.status(201).json({ ...(field as any), isDeleted: !!(field as any).isDeleted }); } catch (error) { res.status(500).json({ error: 'Failed to create field' }); @@ -35,17 +34,16 @@ router.put('/:id', async (req: Request, res: Response) => { try { const updatedAt = Date.now(); const { name, fieldType, fieldOptions, order } = req.body; - const result = db.prepare(` - UPDATE fields - SET name = ?, fieldType = ?, fieldOptions = ?, \`order\` = ?, updatedAt = ? - WHERE id = ? - `).run(name, fieldType, fieldOptions, order, updatedAt, req.params.id); + const result = await dbAdapter.execute( + 'UPDATE fields SET name = ?, fieldType = ?, fieldOptions = ?, "order" = ?, updatedAt = ? WHERE id = ?', + [name, fieldType, fieldOptions, order, updatedAt, req.params.id] + ); if (result.changes === 0) { return res.status(404).json({ error: 'Field not found' }); } - const field = db.prepare('SELECT * FROM fields WHERE id = ?').get(req.params.id); + const field = await dbAdapter.queryOne('SELECT * FROM fields WHERE id = ?', [req.params.id]); res.json({ ...(field as any), isDeleted: !!(field as any).isDeleted }); } catch (error) { res.status(500).json({ error: 'Failed to update field' }); @@ -56,11 +54,10 @@ router.put('/:id', async (req: Request, res: Response) => { router.delete('/:id', async (req: Request, res: Response) => { try { const updatedAt = Date.now(); - const result = db.prepare(` - UPDATE fields - SET isDeleted = 1, updatedAt = ? - WHERE id = ? - `).run(updatedAt, req.params.id); + const result = await dbAdapter.execute( + 'UPDATE fields SET isDeleted = 1, updatedAt = ? WHERE id = ?', + [updatedAt, req.params.id] + ); if (result.changes === 0) { return res.status(404).json({ error: 'Field not found' }); diff --git a/CollabTableServer/src/routes/itemRoutes.ts b/CollabTableServer/src/routes/itemRoutes.ts index 5cd6499..f08282c 100644 --- a/CollabTableServer/src/routes/itemRoutes.ts +++ b/CollabTableServer/src/routes/itemRoutes.ts @@ -1,12 +1,12 @@ import { Router, Request, Response } from 'express'; -import { db } from '../database'; +import { dbAdapter } from '../db'; const router = Router(); // Get items for a list router.get('/list/:listId', async (req: Request, res: Response) => { try { - const items = db.prepare('SELECT * FROM items WHERE listId = ? AND isDeleted = 0 ORDER BY updatedAt DESC').all(req.params.listId); + const items = await dbAdapter.queryAll('SELECT * FROM items WHERE listId = ? AND isDeleted = 0 ORDER BY updatedAt DESC', [req.params.listId]); const formattedItems = (items as any[]).map(item => ({ ...item, isDeleted: !!item.isDeleted })); res.json(formattedItems); } catch (error) { @@ -17,7 +17,7 @@ router.get('/list/:listId', async (req: Request, res: Response) => { // Get item values router.get('/:itemId/values', async (req: Request, res: Response) => { try { - const values = db.prepare('SELECT * FROM item_values WHERE itemId = ?').all(req.params.itemId); + const values = await dbAdapter.queryAll('SELECT * FROM item_values WHERE itemId = ?', [req.params.itemId]); res.json(values); } catch (error) { res.status(500).json({ error: 'Failed to fetch item values' }); @@ -28,12 +28,11 @@ router.get('/:itemId/values', async (req: Request, res: Response) => { router.post('/', async (req: Request, res: Response) => { try { const { id, listId, createdAt, updatedAt, isDeleted } = req.body; - db.prepare(` - INSERT INTO items (id, listId, createdAt, updatedAt, isDeleted) - VALUES (?, ?, ?, ?, ?) - `).run(id, listId, createdAt, updatedAt, isDeleted ? 1 : 0); - - const item = db.prepare('SELECT * FROM items WHERE id = ?').get(id); + await dbAdapter.execute( + 'INSERT INTO items (id, listId, createdAt, updatedAt, isDeleted) VALUES (?, ?, ?, ?, ?)', + [id, listId, createdAt, updatedAt, isDeleted ? 1 : 0] + ); + const item = await dbAdapter.queryOne('SELECT * FROM items WHERE id = ?', [id]); res.status(201).json({ ...(item as any), isDeleted: !!(item as any).isDeleted }); } catch (error) { res.status(500).json({ error: 'Failed to create item' }); @@ -44,15 +43,11 @@ router.post('/', async (req: Request, res: Response) => { router.post('/values', async (req: Request, res: Response) => { try { const { id, itemId, fieldId, value, updatedAt } = req.body; - db.prepare(` - INSERT INTO item_values (id, itemId, fieldId, value, updatedAt) - VALUES (?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET - value = ?, - updatedAt = ? - `).run(id, itemId, fieldId, value, updatedAt, value, updatedAt); - - const itemValue = db.prepare('SELECT * FROM item_values WHERE id = ?').get(id); + await dbAdapter.execute( + 'INSERT INTO item_values (id, itemId, fieldId, value, updatedAt) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET value = ?, updatedAt = ?', + [id, itemId, fieldId, value, updatedAt, value, updatedAt] + ); + const itemValue = await dbAdapter.queryOne('SELECT * FROM item_values WHERE id = ?', [id]); res.json(itemValue); } catch (error) { res.status(500).json({ error: 'Failed to save item value' }); @@ -63,17 +58,16 @@ router.post('/values', async (req: Request, res: Response) => { router.put('/:id', async (req: Request, res: Response) => { try { const updatedAt = Date.now(); - const result = db.prepare(` - UPDATE items - SET updatedAt = ? - WHERE id = ? - `).run(updatedAt, req.params.id); + const result = await dbAdapter.execute( + 'UPDATE items SET updatedAt = ? WHERE id = ?', + [updatedAt, req.params.id] + ); if (result.changes === 0) { return res.status(404).json({ error: 'Item not found' }); } - const item = db.prepare('SELECT * FROM items WHERE id = ?').get(req.params.id); + const item = await dbAdapter.queryOne('SELECT * FROM items WHERE id = ?', [req.params.id]); res.json({ ...(item as any), isDeleted: !!(item as any).isDeleted }); } catch (error) { res.status(500).json({ error: 'Failed to update item' }); @@ -84,11 +78,10 @@ router.put('/:id', async (req: Request, res: Response) => { router.delete('/:id', async (req: Request, res: Response) => { try { const updatedAt = Date.now(); - const result = db.prepare(` - UPDATE items - SET isDeleted = 1, updatedAt = ? - WHERE id = ? - `).run(updatedAt, req.params.id); + const result = await dbAdapter.execute( + 'UPDATE items SET isDeleted = 1, updatedAt = ? WHERE id = ?', + [updatedAt, req.params.id] + ); if (result.changes === 0) { return res.status(404).json({ error: 'Item not found' }); diff --git a/CollabTableServer/src/routes/listRoutes.ts b/CollabTableServer/src/routes/listRoutes.ts index cfed739..bf84dd6 100644 --- a/CollabTableServer/src/routes/listRoutes.ts +++ b/CollabTableServer/src/routes/listRoutes.ts @@ -1,12 +1,12 @@ import { Router, Request, Response } from 'express'; -import { db } from '../database'; +import { dbAdapter } from '../db'; const router = Router(); // Get all lists router.get('/', async (req: Request, res: Response) => { try { - const lists = db.prepare('SELECT * FROM lists WHERE isDeleted = 0 ORDER BY updatedAt DESC').all(); + const lists = await dbAdapter.queryAll('SELECT * FROM lists WHERE isDeleted = 0 ORDER BY updatedAt DESC'); // Convert isDeleted from INTEGER to BOOLEAN const formattedLists = (lists as any[]).map(list => ({ ...list, isDeleted: !!list.isDeleted })); res.json(formattedLists); @@ -18,7 +18,7 @@ router.get('/', async (req: Request, res: Response) => { // Get single list router.get('/:id', async (req: Request, res: Response) => { try { - const list = db.prepare('SELECT * FROM lists WHERE id = ? AND isDeleted = 0').get(req.params.id); + const list = await dbAdapter.queryOne('SELECT * FROM lists WHERE id = ? AND isDeleted = 0', [req.params.id]); if (!list) { return res.status(404).json({ error: 'List not found' }); } @@ -32,12 +32,11 @@ router.get('/:id', async (req: Request, res: Response) => { router.post('/', async (req: Request, res: Response) => { try { const { id, name, createdAt, updatedAt, isDeleted } = req.body; - db.prepare(` - INSERT INTO lists (id, name, createdAt, updatedAt, isDeleted) - VALUES (?, ?, ?, ?, ?) - `).run(id, name, createdAt, updatedAt, isDeleted ? 1 : 0); - - const list = db.prepare('SELECT * FROM lists WHERE id = ?').get(id); + await dbAdapter.execute( + 'INSERT INTO lists (id, name, createdAt, updatedAt, isDeleted) VALUES (?, ?, ?, ?, ?)', + [id, name, createdAt, updatedAt, isDeleted ? 1 : 0] + ); + const list = await dbAdapter.queryOne('SELECT * FROM lists WHERE id = ?', [id]); res.status(201).json({ ...(list as any), isDeleted: !!(list as any).isDeleted }); } catch (error) { res.status(500).json({ error: 'Failed to create list' }); @@ -48,17 +47,15 @@ router.post('/', async (req: Request, res: Response) => { router.put('/:id', async (req: Request, res: Response) => { try { const updatedAt = Date.now(); - const result = db.prepare(` - UPDATE lists - SET name = ?, updatedAt = ? - WHERE id = ? - `).run(req.body.name, updatedAt, req.params.id); - + const result = await dbAdapter.execute( + 'UPDATE lists SET name = ?, updatedAt = ? WHERE id = ?', + [req.body.name, updatedAt, req.params.id] + ); + if (result.changes === 0) { return res.status(404).json({ error: 'List not found' }); } - - const list = db.prepare('SELECT * FROM lists WHERE id = ?').get(req.params.id); + const list = await dbAdapter.queryOne('SELECT * FROM lists WHERE id = ?', [req.params.id]); res.json({ ...(list as any), isDeleted: !!(list as any).isDeleted }); } catch (error) { res.status(500).json({ error: 'Failed to update list' }); @@ -69,12 +66,11 @@ router.put('/:id', async (req: Request, res: Response) => { router.delete('/:id', async (req: Request, res: Response) => { try { const updatedAt = Date.now(); - const result = db.prepare(` - UPDATE lists - SET isDeleted = 1, updatedAt = ? - WHERE id = ? - `).run(updatedAt, req.params.id); - + const result = await dbAdapter.execute( + 'UPDATE lists SET isDeleted = 1, updatedAt = ? WHERE id = ?', + [updatedAt, req.params.id] + ); + if (result.changes === 0) { return res.status(404).json({ error: 'List not found' }); } diff --git a/CollabTableServer/src/routes/syncRoutes.ts b/CollabTableServer/src/routes/syncRoutes.ts index ef84489..eef55a1 100644 --- a/CollabTableServer/src/routes/syncRoutes.ts +++ b/CollabTableServer/src/routes/syncRoutes.ts @@ -1,5 +1,5 @@ import { Router, Request, Response } from 'express'; -import { db } from '../database'; +import { dbAdapter } from '../db'; const router = Router(); @@ -57,43 +57,11 @@ setInterval(() => { }, 60000); // Changed from 30s to 60s // Helper function to get prepared statements (lazy initialization) -function getUpsertStatements() { - return { - upsertList: db.prepare(` - INSERT INTO lists (id, name, createdAt, updatedAt, isDeleted) - VALUES (@id, @name, @createdAt, @updatedAt, @isDeleted) - ON CONFLICT(id) DO UPDATE SET - name = @name, - updatedAt = @updatedAt, - isDeleted = @isDeleted - `), - upsertField: db.prepare(` - INSERT INTO fields (id, name, fieldType, fieldOptions, listId, \`order\`, createdAt, updatedAt, isDeleted) - VALUES (@id, @name, @fieldType, @fieldOptions, @listId, @order, @createdAt, @updatedAt, @isDeleted) - ON CONFLICT(id) DO UPDATE SET - name = @name, - fieldType = @fieldType, - fieldOptions = @fieldOptions, - \`order\` = @order, - updatedAt = @updatedAt, - isDeleted = @isDeleted - `), - upsertItem: db.prepare(` - INSERT INTO items (id, listId, createdAt, updatedAt, isDeleted) - VALUES (@id, @listId, @createdAt, @updatedAt, @isDeleted) - ON CONFLICT(id) DO UPDATE SET - updatedAt = @updatedAt, - isDeleted = @isDeleted - `), - upsertItemValue: db.prepare(` - INSERT INTO item_values (id, itemId, fieldId, value, updatedAt) - VALUES (@id, @itemId, @fieldId, @value, @updatedAt) - ON CONFLICT(id) DO UPDATE SET - value = @value, - updatedAt = @updatedAt - `) - }; -} +// We'll perform upserts with positional params for cross-DB portability +const UPSERT_LIST = 'INSERT INTO lists (id, name, createdAt, updatedAt, isDeleted) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = EXCLUDED.name, updatedAt = EXCLUDED.updatedAt, isDeleted = EXCLUDED.isDeleted'; +const UPSERT_FIELD = 'INSERT INTO fields (id, name, fieldType, fieldOptions, listId, "order", createdAt, updatedAt, isDeleted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = EXCLUDED.name, fieldType = EXCLUDED.fieldType, fieldOptions = EXCLUDED.fieldOptions, "order" = EXCLUDED."order", updatedAt = EXCLUDED.updatedAt, isDeleted = EXCLUDED.isDeleted'; +const UPSERT_ITEM = 'INSERT INTO items (id, listId, createdAt, updatedAt, isDeleted) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET updatedAt = EXCLUDED.updatedAt, isDeleted = EXCLUDED.isDeleted'; +const UPSERT_ITEM_VALUE = 'INSERT INTO item_values (id, itemId, fieldId, value, updatedAt) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET value = EXCLUDED.value, updatedAt = EXCLUDED.updatedAt'; router.post('/sync', async (req: Request, res: Response) => { try { @@ -144,65 +112,60 @@ router.post('/sync', async (req: Request, res: Response) => { } // Save incoming data from client using a transaction - const transaction = db.transaction((data: SyncRequest) => { - // Get prepared statements lazily - const { upsertList, upsertField, upsertItem, upsertItemValue } = getUpsertStatements(); - - if (data.lists && data.lists.length > 0) { - for (const list of data.lists) { - upsertList.run({ - id: list.id, - name: list.name, - createdAt: list.createdAt, - updatedAt: list.updatedAt, - isDeleted: list.isDeleted ? 1 : 0 - }); + await dbAdapter.transaction(async (tx) => { + if (lists && lists.length > 0) { + for (const list of lists) { + await tx.execute(UPSERT_LIST, [ + list.id, + list.name, + list.createdAt, + list.updatedAt, + list.isDeleted ? 1 : 0 + ]); } } - if (data.fields && data.fields.length > 0) { - for (const field of data.fields) { - upsertField.run({ - id: field.id, - name: field.name, - fieldType: field.fieldType, - fieldOptions: field.fieldOptions, - listId: field.listId, - order: field.order, - createdAt: field.createdAt, - updatedAt: field.updatedAt, - isDeleted: field.isDeleted ? 1 : 0 - }); + if (fields && fields.length > 0) { + for (const field of fields) { + await tx.execute(UPSERT_FIELD, [ + field.id, + field.name, + field.fieldType, + field.fieldOptions, + field.listId, + field.order, + field.createdAt, + field.updatedAt, + field.isDeleted ? 1 : 0 + ]); } } - if (data.items && data.items.length > 0) { - for (const item of data.items) { - upsertItem.run({ - id: item.id, - listId: item.listId, - createdAt: item.createdAt, - updatedAt: item.updatedAt, - isDeleted: item.isDeleted ? 1 : 0 - }); + if (items && items.length > 0) { + for (const item of items) { + await tx.execute(UPSERT_ITEM, [ + item.id, + item.listId, + item.createdAt, + item.updatedAt, + item.isDeleted ? 1 : 0 + ]); } } - if (data.itemValues && data.itemValues.length > 0) { - for (const value of data.itemValues) { - upsertItemValue.run({ - id: value.id, - itemId: value.itemId, - fieldId: value.fieldId, - value: value.value, - updatedAt: value.updatedAt - }); + if (itemValues && itemValues.length > 0) { + for (const value of itemValues) { + await tx.execute(UPSERT_ITEM_VALUE, [ + value.id, + value.itemId, + value.fieldId, + value.value, + value.updatedAt + ]); } } }); - transaction({ lastSyncTimestamp, lists, fields, items, itemValues }); - // IMPORTANT: Get updates from server AFTER saving incoming data // This ensures clients receive back any data they just sent (for confirmation) // If lastSyncTimestamp is 0, get all data (initial sync) @@ -210,17 +173,15 @@ router.post('/sync', async (req: Request, res: Response) => { let serverLists, serverFields, serverItems, serverItemValues; if (lastSyncTimestamp === 0) { - // Initial sync: send all non-deleted items (AFTER processing incoming data) - serverLists = db.prepare('SELECT * FROM lists WHERE isDeleted = 0').all(); - serverFields = db.prepare('SELECT * FROM fields WHERE isDeleted = 0').all(); - serverItems = db.prepare('SELECT * FROM items WHERE isDeleted = 0').all(); - serverItemValues = db.prepare('SELECT * FROM item_values').all(); + serverLists = await dbAdapter.queryAll('SELECT * FROM lists WHERE isDeleted = 0'); + serverFields = await dbAdapter.queryAll('SELECT * FROM fields WHERE isDeleted = 0'); + serverItems = await dbAdapter.queryAll('SELECT * FROM items WHERE isDeleted = 0'); + serverItemValues = await dbAdapter.queryAll('SELECT * FROM item_values'); } else { - // Incremental sync: send all updates including deletions (use >= to include items updated exactly at lastSyncTimestamp) - serverLists = db.prepare('SELECT * FROM lists WHERE updatedAt >= ?').all(lastSyncTimestamp); - serverFields = db.prepare('SELECT * FROM fields WHERE updatedAt >= ?').all(lastSyncTimestamp); - serverItems = db.prepare('SELECT * FROM items WHERE updatedAt >= ?').all(lastSyncTimestamp); - serverItemValues = db.prepare('SELECT * FROM item_values WHERE updatedAt >= ?').all(lastSyncTimestamp); + serverLists = await dbAdapter.queryAll('SELECT * FROM lists WHERE updatedAt >= ?', [lastSyncTimestamp]); + serverFields = await dbAdapter.queryAll('SELECT * FROM fields WHERE updatedAt >= ?', [lastSyncTimestamp]); + serverItems = await dbAdapter.queryAll('SELECT * FROM items WHERE updatedAt >= ?', [lastSyncTimestamp]); + serverItemValues = await dbAdapter.queryAll('SELECT * FROM item_values WHERE updatedAt >= ?', [lastSyncTimestamp]); } // Convert isDeleted from INTEGER (0/1) to BOOLEAN diff --git a/CollabTableServer/src/routes/webRoutes.ts b/CollabTableServer/src/routes/webRoutes.ts index 619ea2c..4ab845c 100644 --- a/CollabTableServer/src/routes/webRoutes.ts +++ b/CollabTableServer/src/routes/webRoutes.ts @@ -1,5 +1,5 @@ import { Router, Request, Response } from 'express'; -import { db } from '../database'; +import { dbAdapter } from '../db'; const router = Router(); @@ -306,10 +306,11 @@ router.get('/web/data', async (req: Request, res: Response) => { res.setHeader('Pragma', 'no-cache'); res.setHeader('Expires', '0'); - const lists = db.prepare('SELECT * FROM lists ORDER BY updatedAt DESC').all(); - const fields = db.prepare('SELECT * FROM fields ORDER BY listId ASC, `order` ASC').all(); - const items = db.prepare('SELECT * FROM items ORDER BY listId ASC, updatedAt DESC').all(); - const values = db.prepare('SELECT * FROM item_values').all(); + // Exclude deleted lists and items from web UI + const lists = await dbAdapter.queryAll('SELECT * FROM lists WHERE isDeleted = 0 ORDER BY updatedAt DESC'); + const fields = await dbAdapter.queryAll('SELECT * FROM fields ORDER BY listId ASC, "order" ASC'); + const items = await dbAdapter.queryAll('SELECT * FROM items WHERE isDeleted = 0 ORDER BY listId ASC, updatedAt DESC'); + const values = await dbAdapter.queryAll('SELECT * FROM item_values'); // Convert isDeleted from INTEGER to BOOLEAN const formattedLists = (lists as any[]).map(list => ({ ...list, isDeleted: !!list.isDeleted })); diff --git a/CollabTableServer/src/ws.ts b/CollabTableServer/src/ws.ts new file mode 100644 index 0000000..1e4dff5 --- /dev/null +++ b/CollabTableServer/src/ws.ts @@ -0,0 +1,106 @@ +import { Server as HttpServer, IncomingMessage } from 'http'; +import { WebSocketServer, WebSocket } from 'ws'; +import { dbAdapter } from './db'; + +interface SyncRequest { + lastSyncTimestamp: number; + lists: any[]; + fields: any[]; + items: any[]; + itemValues: any[]; +} + +interface MessageEnvelope { + id?: string; + type: string; + payload?: T; +} + +function checkAuth(req: any): boolean { + const serverPassword = process.env.SERVER_PASSWORD; + if (!serverPassword) return true; // auth disabled + const auth = req.headers?.authorization as string | undefined; + if (!auth) return false; + const parts = auth.split(' '); + return parts.length === 2 && parts[0] === 'Bearer' && parts[1] === serverPassword; +} + +const UPSERT_LIST = 'INSERT INTO lists (id, name, createdAt, updatedAt, isDeleted) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = EXCLUDED.name, updatedAt = EXCLUDED.updatedAt, isDeleted = EXCLUDED.isDeleted'; +const UPSERT_FIELD = 'INSERT INTO fields (id, name, fieldType, fieldOptions, listId, "order", createdAt, updatedAt, isDeleted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = EXCLUDED.name, fieldType = EXCLUDED.fieldType, fieldOptions = EXCLUDED.fieldOptions, "order" = EXCLUDED."order", updatedAt = EXCLUDED.updatedAt, isDeleted = EXCLUDED.isDeleted'; +const UPSERT_ITEM = 'INSERT INTO items (id, listId, createdAt, updatedAt, isDeleted) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET updatedAt = EXCLUDED.updatedAt, isDeleted = EXCLUDED.isDeleted'; +const UPSERT_ITEM_VALUE = 'INSERT INTO item_values (id, itemId, fieldId, value, updatedAt) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET value = EXCLUDED.value, updatedAt = EXCLUDED.updatedAt'; + +async function processSync(data: SyncRequest) { + await dbAdapter.transaction(async (tx) => { + for (const list of data.lists || []) { + await tx.execute(UPSERT_LIST, [list.id, list.name, list.createdAt, list.updatedAt, list.isDeleted ? 1 : 0]); + } + for (const field of data.fields || []) { + await tx.execute(UPSERT_FIELD, [field.id, field.name, field.fieldType, field.fieldOptions, field.listId, field.order, field.createdAt, field.updatedAt, field.isDeleted ? 1 : 0]); + } + for (const item of data.items || []) { + await tx.execute(UPSERT_ITEM, [item.id, item.listId, item.createdAt, item.updatedAt, item.isDeleted ? 1 : 0]); + } + for (const val of data.itemValues || []) { + await tx.execute(UPSERT_ITEM_VALUE, [val.id, val.itemId, val.fieldId, val.value, val.updatedAt]); + } + }); + + let serverLists: any[], serverFields: any[], serverItems: any[], serverItemValues: any[]; + const since = data.lastSyncTimestamp; + if (since === 0) { + serverLists = await dbAdapter.queryAll('SELECT * FROM lists WHERE isDeleted = 0'); + serverFields = await dbAdapter.queryAll('SELECT * FROM fields WHERE isDeleted = 0'); + serverItems = await dbAdapter.queryAll('SELECT * FROM items WHERE isDeleted = 0'); + serverItemValues = await dbAdapter.queryAll('SELECT * FROM item_values'); + } else { + serverLists = await dbAdapter.queryAll('SELECT * FROM lists WHERE updatedAt >= ?', [since]); + serverFields = await dbAdapter.queryAll('SELECT * FROM fields WHERE updatedAt >= ?', [since]); + serverItems = await dbAdapter.queryAll('SELECT * FROM items WHERE updatedAt >= ?', [since]); + serverItemValues = await dbAdapter.queryAll('SELECT * FROM item_values WHERE updatedAt >= ?', [since]); + } + + serverLists = serverLists.map(l => ({ ...l, isDeleted: !!l.isDeleted })); + serverFields = serverFields.map(f => ({ ...f, isDeleted: !!f.isDeleted })); + serverItems = serverItems.map(i => ({ ...i, isDeleted: !!i.isDeleted })); + + return { + lists: serverLists, + fields: serverFields, + items: serverItems, + itemValues: serverItemValues, + serverTimestamp: Date.now(), + }; +} + +export function initWebSocket(server: HttpServer) { + const wss = new WebSocketServer({ server, path: '/api/ws' }); + console.log('WebSocket server initialized at /api/ws'); + + wss.on('connection', (ws: WebSocket, req: IncomingMessage) => { + if (!checkAuth(req)) { + ws.close(1008, 'Unauthorized'); + return; + } + + ws.on('message', async (raw: Buffer) => { + try { + const msg = JSON.parse(raw.toString()) as MessageEnvelope; + if (msg.type === 'ping') { + const resp: MessageEnvelope = { type: 'pong', id: msg.id }; + ws.send(JSON.stringify(resp)); + return; + } + if (msg.type === 'sync') { + const respData = await processSync(msg.payload as SyncRequest); + const resp: MessageEnvelope = { type: 'syncResponse', id: msg.id, payload: respData }; + ws.send(JSON.stringify(resp)); + return; + } + } catch (e) { + const err: MessageEnvelope = { type: 'error', payload: { message: 'Invalid message' } }; + ws.send(JSON.stringify(err)); + } + }); + }); +} diff --git a/CollabTableServer/tsconfig.json b/CollabTableServer/tsconfig.json index f8ca20c..abf0b7f 100644 --- a/CollabTableServer/tsconfig.json +++ b/CollabTableServer/tsconfig.json @@ -3,7 +3,7 @@ "target": "ES2020", "module": "commonjs", "lib": ["ES2020"], - "types": ["node"], + "types": ["node", "ws"], "outDir": "./dist", "rootDir": "./src", "strict": true,