From 8cec26d669b93fe2de0bc97bc8fe68cb0e5e3df5 Mon Sep 17 00:00:00 2001 From: Andy Date: Sat, 4 Apr 2026 00:57:17 +0200 Subject: [PATCH] feat: add optional alias for shopping lists Add human-friendly aliases (e.g. 'groceries') as an optional setting per list, usable in place of the UUID. Aliases are unique among active lists, normalized to lowercase, and cleaned up automatically with list expiry. Prepares for future MCP/AI integration. Backend: - New column 'alias' on shopping_lists with unique index - GET /api/lists/by-alias/{alias} to resolve alias to list - PUT /api/lists/{id}/alias to set, update, or remove alias - Full validation (format, length, UUID-rejection, conflict 409) - Switch SchemaUtils.create() to createMissingTablesAndColumns() so new columns are auto-added on deployment - 11 new tests (7 route + 4 repository) Frontend: - Alias settings UI in BurgerMenu (set/edit/remove with validation) - API client methods for alias endpoints - i18n keys in en.json and de.json --- CHANGELOG.md | 4 + README.md | 5 +- .../shoppinglist/database/DatabaseFactory.kt | 2 +- .../com/shoppinglist/database/Tables.kt | 1 + .../com/shoppinglist/models/ShoppingList.kt | 13 +- .../shoppinglist/repository/ListRepository.kt | 5 +- .../repository/ListRepositoryImpl.kt | 59 +++++- .../com/shoppinglist/routes/ListRoutes.kt | 126 ++++++++++++ .../repository/ListRepositoryTest.kt | 51 +++++ .../com/shoppinglist/routes/ListRoutesTest.kt | 180 ++++++++++++++++++ frontend/src/components/BurgerMenu.tsx | 89 ++++++++- frontend/src/components/ListPage.tsx | 9 +- frontend/src/services/api.ts | 11 ++ frontend/src/translations/de.json | 11 +- frontend/src/translations/en.json | 11 +- frontend/src/types/index.ts | 1 + 16 files changed, 564 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37ab028..4ceab74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **List alias** — Optional human-friendly alias (e.g. `groceries`) for shopping lists, usable in place of the UUID; aliases are unique among active lists, normalized to lowercase, and deleted automatically when the list expires. New endpoints: `GET /api/lists/by-alias/{alias}` to resolve an alias, `PUT /api/lists/{id}/alias` to set, update, or remove it. Small settings UI in the burger menu for managing the alias per list. - **AGENTS.md** — AI coding agent instructions covering project overview, tech stack, exact commands, coding conventions, development lifecycle (branch-per-change, Conventional Commits, CHANGELOG discipline), PR workflow, and three-tier boundaries (always/ask/never) +### Changed +- **Database schema initialization** — Switched from `SchemaUtils.create()` to `SchemaUtils.createMissingTablesAndColumns()` so new columns (like alias) are automatically added to existing tables on deployment without manual migration + ### Fixed - **Flaky backend tests** — Resolved intermittent `PSQLException: Connection refused` errors in CI by fixing Testcontainers lifecycle mismatch (`@Container` moved to `companion object` for all `@TestInstance(PER_CLASS)` test classes) and disabling parallel test-class execution (`maxParallelForks = 1`) to prevent Exposed's global database registry from being overwritten mid-test by a concurrently running test class diff --git a/README.md b/README.md index 1c9c0f3..e8055b8 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ A real-time collaborative shopping list app. Create a list, share the link, and - **Dark Mode** — Light, dark, and system-preference theme toggle - **Smart Autocomplete** — Suggests items from your cross-list history as you type - **Push Notifications** — Opt-in Web Push alerts when list changes happen +- **List Aliases** — Optionally assign a human-friendly alias (e.g. `groceries`) to any list, usable in place of the UUID - **Prometheus Metrics** — Application metrics (online users, list count, JVM, HTTP requests) exposed for Prometheus scraping ## Tech Stack @@ -134,6 +135,8 @@ For production deployment, see [DEPLOYMENT.md](DEPLOYMENT.md). | `PUT` | `/api/lists/{id}/items/{itemId}` | Update an item | | `DELETE` | `/api/lists/{id}/items/{itemId}` | Delete an item | | `POST` | `/api/lists/{id}/clear-completed` | Remove completed items | +| `GET` | `/api/lists/by-alias/{alias}` | Resolve a list by its alias | +| `PUT` | `/api/lists/{id}/alias` | Set, update, or remove a list's alias | | `WS` | `/ws/{listId}` | Real-time WebSocket connection | | `GET` | `/api/push/vapid-key` | Get the server's VAPID public key | | `POST` | `/api/push/subscribe` | Subscribe to push notifications | @@ -145,7 +148,7 @@ Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) before ## Security -This app has no authentication — lists are accessible to anyone who knows the UUID. See [SECURITY.md](SECURITY.md) for deployment guidance and how to report vulnerabilities. +This app has no authentication — lists are accessible to anyone who knows the UUID or alias. See [SECURITY.md](SECURITY.md) for deployment guidance and how to report vulnerabilities. ## License diff --git a/backend/src/main/kotlin/com/shoppinglist/database/DatabaseFactory.kt b/backend/src/main/kotlin/com/shoppinglist/database/DatabaseFactory.kt index 14a07cd..1f43eb3 100644 --- a/backend/src/main/kotlin/com/shoppinglist/database/DatabaseFactory.kt +++ b/backend/src/main/kotlin/com/shoppinglist/database/DatabaseFactory.kt @@ -12,7 +12,7 @@ object DatabaseFactory { val database = Database.connect(createHikariDataSource()) transaction(database) { - SchemaUtils.create(ShoppingLists, ListItems, PushSubscriptions) + SchemaUtils.createMissingTablesAndColumns(ShoppingLists, ListItems, PushSubscriptions) } } diff --git a/backend/src/main/kotlin/com/shoppinglist/database/Tables.kt b/backend/src/main/kotlin/com/shoppinglist/database/Tables.kt index b54e545..e0168c0 100644 --- a/backend/src/main/kotlin/com/shoppinglist/database/Tables.kt +++ b/backend/src/main/kotlin/com/shoppinglist/database/Tables.kt @@ -7,6 +7,7 @@ import java.time.Instant object ShoppingLists : UUIDTable("shopping_lists") { val createdAt = timestamp("created_at").default(Instant.now()) val lastModified = timestamp("last_modified").default(Instant.now()) + val alias = varchar("alias", 64).nullable().uniqueIndex("uq_shopping_lists_alias") } object ListItems : UUIDTable("list_items") { diff --git a/backend/src/main/kotlin/com/shoppinglist/models/ShoppingList.kt b/backend/src/main/kotlin/com/shoppinglist/models/ShoppingList.kt index bf8c487..111c072 100644 --- a/backend/src/main/kotlin/com/shoppinglist/models/ShoppingList.kt +++ b/backend/src/main/kotlin/com/shoppinglist/models/ShoppingList.kt @@ -9,7 +9,8 @@ data class ShoppingList( val items: List = emptyList(), val createdAt: String, val lastModified: String, - val expiresAt: String? = null + val expiresAt: String? = null, + val alias: String? = null ) @Serializable @@ -23,5 +24,11 @@ data class ShoppingListResponse( val items: List, val createdAt: String, val lastModified: String, - val expiresAt: String? = null -) \ No newline at end of file + val expiresAt: String? = null, + val alias: String? = null +) + +@Serializable +data class UpdateAliasRequest( + val alias: String? = null +) diff --git a/backend/src/main/kotlin/com/shoppinglist/repository/ListRepository.kt b/backend/src/main/kotlin/com/shoppinglist/repository/ListRepository.kt index 25770b4..908d4e5 100644 --- a/backend/src/main/kotlin/com/shoppinglist/repository/ListRepository.kt +++ b/backend/src/main/kotlin/com/shoppinglist/repository/ListRepository.kt @@ -10,4 +10,7 @@ interface ListRepository { suspend fun deleteList(id: UUID): Boolean suspend fun listExists(id: UUID): Boolean suspend fun getListWithExpiration(id: UUID): ShoppingList? -} \ No newline at end of file + suspend fun getListByAlias(alias: String): ShoppingList? + suspend fun updateAlias(id: UUID, alias: String?): Boolean + suspend fun aliasExists(alias: String): Boolean +} diff --git a/backend/src/main/kotlin/com/shoppinglist/repository/ListRepositoryImpl.kt b/backend/src/main/kotlin/com/shoppinglist/repository/ListRepositoryImpl.kt index 9e3c6da..2d90e1e 100644 --- a/backend/src/main/kotlin/com/shoppinglist/repository/ListRepositoryImpl.kt +++ b/backend/src/main/kotlin/com/shoppinglist/repository/ListRepositoryImpl.kt @@ -51,7 +51,8 @@ class ListRepositoryImpl : ListRepository { id = listRow[ShoppingLists.id].toString(), items = items, createdAt = listRow[ShoppingLists.createdAt].toString(), - lastModified = listRow[ShoppingLists.lastModified].toString() + lastModified = listRow[ShoppingLists.lastModified].toString(), + alias = listRow[ShoppingLists.alias] ) } @@ -100,7 +101,59 @@ class ListRepositoryImpl : ListRepository { items = items, createdAt = listRow[ShoppingLists.createdAt].toString(), lastModified = lastModified.toString(), - expiresAt = expiresAt.toString() + expiresAt = expiresAt.toString(), + alias = listRow[ShoppingLists.alias] ) } -} \ No newline at end of file + + override suspend fun getListByAlias(alias: String): ShoppingList? = newSuspendedTransaction { + val normalizedAlias = alias.trim().lowercase() + + val listRow = ShoppingLists.select { ShoppingLists.alias eq normalizedAlias }.singleOrNull() + ?: return@newSuspendedTransaction null + + val listId = listRow[ShoppingLists.id].value + + val items = ListItems + .select { ListItems.listId eq listId } + .orderBy(ListItems.itemOrder) + .map { row -> + ListItem( + id = row[ListItems.id].toString(), + text = row[ListItems.text], + completed = row[ListItems.completed], + createdAt = row[ListItems.createdAt].toString(), + order = row[ListItems.itemOrder] + ) + } + + val retentionDays = System.getenv("LIST_RETENTION_DAYS")?.toLongOrNull() + ?: System.getProperty("LIST_RETENTION_DAYS")?.toLongOrNull() + ?: 30L + val lastModified = listRow[ShoppingLists.lastModified] + val expiresAt = lastModified.plus(retentionDays, ChronoUnit.DAYS) + + ShoppingList( + id = listRow[ShoppingLists.id].toString(), + items = items, + createdAt = listRow[ShoppingLists.createdAt].toString(), + lastModified = lastModified.toString(), + expiresAt = expiresAt.toString(), + alias = listRow[ShoppingLists.alias] + ) + } + + override suspend fun updateAlias(id: UUID, alias: String?): Boolean = newSuspendedTransaction { + val normalizedAlias = alias?.trim()?.lowercase() + + val updatedRows = ShoppingLists.update({ ShoppingLists.id eq id }) { + it[ShoppingLists.alias] = normalizedAlias + } + updatedRows > 0 + } + + override suspend fun aliasExists(alias: String): Boolean = newSuspendedTransaction { + val normalizedAlias = alias.trim().lowercase() + ShoppingLists.select { ShoppingLists.alias eq normalizedAlias }.count() > 0 + } +} diff --git a/backend/src/main/kotlin/com/shoppinglist/routes/ListRoutes.kt b/backend/src/main/kotlin/com/shoppinglist/routes/ListRoutes.kt index bd956e7..0e811af 100644 --- a/backend/src/main/kotlin/com/shoppinglist/routes/ListRoutes.kt +++ b/backend/src/main/kotlin/com/shoppinglist/routes/ListRoutes.kt @@ -427,5 +427,131 @@ fun Route.listRoutes(pushNotificationService: PushNotificationService? = null) { ) } } + + // GET /api/lists/by-alias/{alias} - Resolve alias to list + get("/by-alias/{alias}") { + try { + val aliasParam = call.parameters["alias"] + if (aliasParam.isNullOrBlank()) { + call.respond( + HttpStatusCode.BadRequest, + ErrorResponse("missing_alias", "Alias is required") + ) + return@get + } + + val normalizedAlias = aliasParam.trim().lowercase() + + val list = listRepository.getListByAlias(normalizedAlias) + if (list == null) { + call.respond( + HttpStatusCode.NotFound, + ErrorResponse("list_not_found", "No list found with this alias") + ) + return@get + } + + call.respond(HttpStatusCode.OK, list) + } catch (e: Exception) { + call.respond( + HttpStatusCode.InternalServerError, + ErrorResponse("server_error", "Failed to resolve alias") + ) + } + } + + // PUT /api/lists/{id}/alias - Set/update/remove alias + put("/{id}/alias") { + try { + val idParam = call.parameters["id"] + if (idParam == null) { + call.respond( + HttpStatusCode.BadRequest, + ErrorResponse("missing_id", "List ID is required") + ) + return@put + } + + val listId = try { + UUID.fromString(idParam) + } catch (e: IllegalArgumentException) { + call.respond( + HttpStatusCode.BadRequest, + ErrorResponse("invalid_id", "Invalid UUID format for list ID") + ) + return@put + } + + if (!listRepository.listExists(listId)) { + call.respond( + HttpStatusCode.NotFound, + ErrorResponse("list_not_found", "Shopping list not found") + ) + return@put + } + + val request = try { + call.receive() + } catch (e: Exception) { + call.respond( + HttpStatusCode.BadRequest, + ErrorResponse("invalid_request", "Invalid request body") + ) + return@put + } + + val normalizedAlias = request.alias?.trim()?.lowercase() + + if (normalizedAlias != null) { + val trimmedInput = request.alias!!.trim() + val aliasRegex = Regex("^[a-z0-9]([a-z0-9-]*[a-z0-9])?\$") + if (!aliasRegex.matches(trimmedInput) || trimmedInput.length > 64) { + call.respond( + HttpStatusCode.BadRequest, + ErrorResponse("invalid_alias", "Alias must be 1-64 characters, lowercase alphanumeric and hyphens only, cannot start or end with a hyphen") + ) + return@put + } + + val uuidRegex = Regex("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\$") + if (uuidRegex.matches(normalizedAlias)) { + call.respond( + HttpStatusCode.BadRequest, + ErrorResponse("invalid_alias", "Alias cannot be in UUID format") + ) + return@put + } + + if (listRepository.aliasExists(normalizedAlias)) { + val currentList = listRepository.getListWithExpiration(listId) + if (currentList?.alias != normalizedAlias) { + call.respond( + HttpStatusCode.Conflict, + ErrorResponse("alias_taken", "This alias is already in use by another list") + ) + return@put + } + } + } + + listRepository.updateAlias(listId, normalizedAlias) + + val updatedList = listRepository.getListWithExpiration(listId) + if (updatedList == null) { + call.respond( + HttpStatusCode.NotFound, + ErrorResponse("list_not_found", "Shopping list not found") + ) + return@put + } + + call.respond(HttpStatusCode.OK, updatedList) + } catch (e: Exception) { + call.respond( + HttpStatusCode.InternalServerError, + ErrorResponse("server_error", "Failed to update alias") + ) + } + } } } \ No newline at end of file diff --git a/backend/src/test/kotlin/com/shoppinglist/repository/ListRepositoryTest.kt b/backend/src/test/kotlin/com/shoppinglist/repository/ListRepositoryTest.kt index 150183e..58252ff 100644 --- a/backend/src/test/kotlin/com/shoppinglist/repository/ListRepositoryTest.kt +++ b/backend/src/test/kotlin/com/shoppinglist/repository/ListRepositoryTest.kt @@ -19,6 +19,7 @@ import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlin.test.assertFalse @Testcontainers @TestInstance(TestInstance.Lifecycle.PER_CLASS) @@ -137,4 +138,54 @@ class ListRepositoryTest { assertTrue(deleted) assertNull(retrievedList) } + + @Test + fun `getListByAlias should return list with matching alias`() = runBlocking { + val listId = UUID.randomUUID() + listRepository.createList(listId) + listRepository.updateAlias(listId, "test-alias") + + val retrievedList = listRepository.getListByAlias("test-alias") + + assertNotNull(retrievedList) + assertEquals(listId.toString(), retrievedList.id) + assertEquals("test-alias", retrievedList.alias) + } + + @Test + fun `getListByAlias should return null for non-existing alias`() = runBlocking { + val retrievedList = listRepository.getListByAlias("nonexistent") + + assertNull(retrievedList) + } + + @Test + fun `updateAlias should set and clear alias`() = runBlocking { + val listId = UUID.randomUUID() + listRepository.createList(listId) + + val setResult = listRepository.updateAlias(listId, "my-alias") + assertTrue(setResult) + + val listWithAlias = listRepository.getListById(listId) + assertNotNull(listWithAlias) + assertEquals("my-alias", listWithAlias.alias) + + val clearResult = listRepository.updateAlias(listId, null) + assertTrue(clearResult) + + val listWithoutAlias = listRepository.getListById(listId) + assertNotNull(listWithoutAlias) + assertNull(listWithoutAlias.alias) + } + + @Test + fun `aliasExists should return true for existing alias`() = runBlocking { + val listId = UUID.randomUUID() + listRepository.createList(listId) + listRepository.updateAlias(listId, "existing-alias") + + assertTrue(listRepository.aliasExists("existing-alias")) + assertFalse(listRepository.aliasExists("nonexistent-alias")) + } } \ No newline at end of file diff --git a/backend/src/test/kotlin/com/shoppinglist/routes/ListRoutesTest.kt b/backend/src/test/kotlin/com/shoppinglist/routes/ListRoutesTest.kt index 60e6b29..773bf04 100644 --- a/backend/src/test/kotlin/com/shoppinglist/routes/ListRoutesTest.kt +++ b/backend/src/test/kotlin/com/shoppinglist/routes/ListRoutesTest.kt @@ -22,6 +22,7 @@ import org.testcontainers.junit.jupiter.Testcontainers import java.util.* import kotlin.test.assertEquals import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue @Testcontainers @@ -534,4 +535,183 @@ class ListRoutesTest { assertEquals("Item 1", retrievedList.items[0].text) assertEquals("Item 2", retrievedList.items[1].text) } + + @Test + fun `PUT api lists id alias should set alias on list`() = testApplication { + application { + configureSerialization() + configureCORS() + configureRouting() + } + + val createResponse = client.post("/api/lists") { + contentType(ContentType.Application.Json) + setBody("{}") + } + val createdList = json.decodeFromString(createResponse.bodyAsText()) + + val aliasResponse = client.put("/api/lists/${createdList.id}/alias") { + contentType(ContentType.Application.Json) + setBody("""{"alias": "my-groceries"}""") + } + + assertEquals(HttpStatusCode.OK, aliasResponse.status) + val updatedList = json.decodeFromString(aliasResponse.bodyAsText()) + assertEquals("my-groceries", updatedList.alias) + } + + @Test + fun `PUT api lists id alias should reject invalid alias format`() = testApplication { + application { + configureSerialization() + configureCORS() + configureRouting() + } + + val createResponse = client.post("/api/lists") { + contentType(ContentType.Application.Json) + setBody("{}") + } + val createdList = json.decodeFromString(createResponse.bodyAsText()) + + val invalidAliases = listOf("has spaces", "UPPER", "special!char", "-starts-with-hyphen", "ends-with-hyphen-") + for (invalidAlias in invalidAliases) { + val response = client.put("/api/lists/${createdList.id}/alias") { + contentType(ContentType.Application.Json) + setBody("""{"alias": "$invalidAlias"}""") + } + assertEquals(HttpStatusCode.BadRequest, response.status, "Expected 400 for alias: $invalidAlias") + } + } + + @Test + fun `PUT api lists id alias should reject duplicate alias`() = testApplication { + application { + configureSerialization() + configureCORS() + configureRouting() + } + + val list1Response = client.post("/api/lists") { + contentType(ContentType.Application.Json) + setBody("{}") + } + val list1 = json.decodeFromString(list1Response.bodyAsText()) + + val list2Response = client.post("/api/lists") { + contentType(ContentType.Application.Json) + setBody("{}") + } + val list2 = json.decodeFromString(list2Response.bodyAsText()) + + client.put("/api/lists/${list1.id}/alias") { + contentType(ContentType.Application.Json) + setBody("""{"alias": "shared-alias"}""") + } + + val conflictResponse = client.put("/api/lists/${list2.id}/alias") { + contentType(ContentType.Application.Json) + setBody("""{"alias": "shared-alias"}""") + } + + assertEquals(HttpStatusCode.Conflict, conflictResponse.status) + val responseBody = conflictResponse.bodyAsText() + assertTrue(responseBody.contains("alias_taken")) + } + + @Test + fun `PUT api lists id alias should allow removing alias`() = testApplication { + application { + configureSerialization() + configureCORS() + configureRouting() + } + + val createResponse = client.post("/api/lists") { + contentType(ContentType.Application.Json) + setBody("{}") + } + val createdList = json.decodeFromString(createResponse.bodyAsText()) + + client.put("/api/lists/${createdList.id}/alias") { + contentType(ContentType.Application.Json) + setBody("""{"alias": "temp-alias"}""") + } + + val removeResponse = client.put("/api/lists/${createdList.id}/alias") { + contentType(ContentType.Application.Json) + setBody("""{"alias": null}""") + } + + assertEquals(HttpStatusCode.OK, removeResponse.status) + val updatedList = json.decodeFromString(removeResponse.bodyAsText()) + assertNull(updatedList.alias) + } + + @Test + fun `GET api lists by-alias alias should resolve alias to list`() = testApplication { + application { + configureSerialization() + configureCORS() + configureRouting() + } + + val createResponse = client.post("/api/lists") { + contentType(ContentType.Application.Json) + setBody("{}") + } + val createdList = json.decodeFromString(createResponse.bodyAsText()) + + client.put("/api/lists/${createdList.id}/alias") { + contentType(ContentType.Application.Json) + setBody("""{"alias": "weekly-shop"}""") + } + + val resolveResponse = client.get("/api/lists/by-alias/weekly-shop") + + assertEquals(HttpStatusCode.OK, resolveResponse.status) + val resolvedList = json.decodeFromString(resolveResponse.bodyAsText()) + assertEquals(createdList.id, resolvedList.id) + assertEquals("weekly-shop", resolvedList.alias) + } + + @Test + fun `GET api lists by-alias alias should return 404 for unknown alias`() = testApplication { + application { + configureSerialization() + configureCORS() + configureRouting() + } + + val response = client.get("/api/lists/by-alias/nonexistent") + + assertEquals(HttpStatusCode.NotFound, response.status) + val responseBody = response.bodyAsText() + assertTrue(responseBody.contains("No list found with this alias")) + } + + @Test + fun `PUT api lists id alias should reject UUID-format aliases`() = testApplication { + application { + configureSerialization() + configureCORS() + configureRouting() + } + + val createResponse = client.post("/api/lists") { + contentType(ContentType.Application.Json) + setBody("{}") + } + val createdList = json.decodeFromString(createResponse.bodyAsText()) + + val uuidAlias = UUID.randomUUID().toString() + val response = client.put("/api/lists/${createdList.id}/alias") { + contentType(ContentType.Application.Json) + setBody("""{"alias": "$uuidAlias"}""") + } + + assertEquals(HttpStatusCode.BadRequest, response.status) + val responseBody = response.bodyAsText() + assertTrue(responseBody.contains("UUID format")) + } } \ No newline at end of file diff --git a/frontend/src/components/BurgerMenu.tsx b/frontend/src/components/BurgerMenu.tsx index 5fae3ce..d432b8a 100644 --- a/frontend/src/components/BurgerMenu.tsx +++ b/frontend/src/components/BurgerMenu.tsx @@ -3,13 +3,59 @@ import { createPortal } from 'react-dom'; import ThemeToggle from './ThemeToggle'; import LanguageSwitcher from './LanguageSwitcher'; import { useI18n } from '../context/I18nContext'; +import { apiClient } from '../services/api'; -const BurgerMenu: React.FC = () => { +interface BurgerMenuProps { + listId?: string; + currentAlias?: string | null; + onAliasChanged?: (alias: string | null) => void; +} + +const BurgerMenu: React.FC = ({ listId, currentAlias, onAliasChanged }) => { const [isOpen, setIsOpen] = useState(false); + const [aliasInput, setAliasInput] = useState(currentAlias || ''); + const [aliasSaving, setAliasSaving] = useState(false); + const [aliasError, setAliasError] = useState(null); + const [aliasSuccess, setAliasSuccess] = useState(null); const menuRef = useRef(null); const sheetRef = useRef(null); const { t } = useI18n(); + useEffect(() => { + setAliasInput(currentAlias || ''); + setAliasError(null); + setAliasSuccess(null); + }, [currentAlias, isOpen]); + + const handleAliasSave = async () => { + if (!listId) return; + setAliasSaving(true); + setAliasError(null); + setAliasSuccess(null); + try { + const trimmed = aliasInput.trim().toLowerCase(); + if (trimmed && !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(trimmed)) { + setAliasError(t('settings.aliasInvalid')); + setAliasSaving(false); + return; + } + const newAlias = trimmed || null; + const updated = await apiClient.updateAlias(listId, newAlias); + onAliasChanged?.(updated.alias ?? null); + setAliasSuccess(newAlias ? t('settings.aliasSaved') : t('settings.aliasRemoved')); + setTimeout(() => setAliasSuccess(null), 2000); + } catch (error: unknown) { + const apiError = error as { status?: number }; + if (apiError.status === 409) { + setAliasError(t('settings.aliasConflict')); + } else { + setAliasError(t('messages.somethingWentWrong')); + } + } finally { + setAliasSaving(false); + } + }; + // Close on outside click useEffect(() => { if (!isOpen) return; @@ -45,6 +91,47 @@ const BurgerMenu: React.FC = () => { const settingsContent = ( <> + {listId && ( + <> +
+ +

+ {t('settings.aliasDescription')} +

+
+ { + setAliasInput(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '')); + setAliasError(null); + setAliasSuccess(null); + }} + placeholder={t('settings.aliasPlaceholder')} + maxLength={64} + className="flex-1 px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400" + /> + +
+ {aliasError && ( +

{aliasError}

+ )} + {aliasSuccess && ( +

{aliasSuccess}

+ )} +
+
+ + )} + {/* Theme section */}