-
-
+
diff --git a/frontend/src/utils/homeLayout.test.ts b/frontend/src/utils/homeLayout.test.ts
index e0767f8..3ecf3ab 100644
--- a/frontend/src/utils/homeLayout.test.ts
+++ b/frontend/src/utils/homeLayout.test.ts
@@ -1,14 +1,21 @@
import { describe, expect, it } from 'vitest'
import {
+ addHomeAppToFolder,
addHomePage,
+ createHomeFolder,
createDefaultHomeLayout,
deleteHomePage,
+ extractHomeFolderApp,
+ getHomeFolder,
HOME_GRID_PAGE_SIZE,
homeKeyboardTarget,
+ isHomeFolder,
MAX_HOME_GRID_PAGES,
+ moveHomeFolderApp,
moveHomeApp,
parseHomeLayout,
+ renameHomeFolder,
removeHomeApp,
restoreHomeApp,
type HomeLayout,
@@ -35,7 +42,7 @@ describe('home layout', () => {
null,
])
expect(layout.dock).toEqual(['phone', 'messages', 'clock', null])
- expect(layout.version).toBe(3)
+ expect(layout.version).toBe(5)
})
it('migrates compact persisted arrays and appends newly installed apps', () => {
@@ -52,12 +59,12 @@ describe('home layout', () => {
expect(layout.dock).toEqual(['messages', null, null, null])
expect(layout.grid.slice(0, 4)).toEqual(['mail', 'clock', 'notes', null])
expect(layout.hidden).toEqual(['phone'])
- expect(layout.version).toBe(3)
+ expect(layout.version).toBe(5)
})
it('preserves explicit gaps in versioned layouts', () => {
const grid: HomeLayout['grid'] = Array.from(
- { length: HOME_GRID_PAGE_SIZE },
+ { length: 20 },
() => null,
)
grid[0] = 'phone'
@@ -82,7 +89,7 @@ describe('home layout', () => {
it('keeps valid custom-app tombstones in version 3 layouts', () => {
const grid: HomeLayout['grid'] = Array.from(
- { length: HOME_GRID_PAGE_SIZE },
+ { length: 20 },
() => null,
)
grid[6] = 'temporarily-missing' as HomeLayout['hidden'][number]
@@ -99,12 +106,35 @@ describe('home layout', () => {
)
expect(layout.grid[6]).toBe('temporarily-missing')
- expect(layout.version).toBe(3)
+ expect(layout.version).toBe(5)
+ })
+
+ it('expands version 3 capacity without compacting persisted gaps', () => {
+ const grid: HomeLayout['grid'] = Array.from({ length: 40 }, () => null)
+ grid[19] = 'mail'
+ grid[20] = 'notes'
+
+ const layout = parseHomeLayout(
+ {
+ dock: ['phone', null, null, null],
+ grid,
+ hidden: [],
+ version: 3,
+ },
+ defaults,
+ [...installed],
+ )
+
+ expect(layout.grid).toHaveLength(HOME_GRID_PAGE_SIZE * 2)
+ expect(layout.grid[19]).toBe('mail')
+ expect(layout.grid[20]).toBe('notes')
+ expect(layout.grid.slice(21, 24)).toEqual([null, null, null])
+ expect(layout.version).toBe(5)
})
it('preserves independently positioned shortcuts for the same app', () => {
const grid: HomeLayout['grid'] = Array.from(
- { length: HOME_GRID_PAGE_SIZE },
+ { length: 20 },
() => null,
)
grid[0] = 'phone'
@@ -144,33 +174,33 @@ describe('home layout', () => {
expect(homeKeyboardTarget(defaults, 'dock', 1, 'down')).toBeNull()
})
- it('shifts occupied grid slots instead of replacing their apps', () => {
+ it('swaps occupied grid slots without moving unrelated apps', () => {
const reordered = moveHomeApp(defaults, 'grid', 2, 'grid', 0)
expect(reordered.grid.slice(0, 5)).toEqual([
'mail',
- 'phone',
'messages',
+ 'phone',
'clock',
'notes',
])
})
- it('shifts an occupied dock slot toward its gap', () => {
+ it('swaps an occupied dock slot with the exact grid source', () => {
const docked = moveHomeApp(defaults, 'grid', 2, 'dock', 2)
- expect(docked.dock).toEqual(['phone', 'messages', 'mail', 'clock'])
- expect(docked.grid[2]).toBeNull()
+ expect(docked.dock).toEqual(['phone', 'messages', 'mail', null])
+ expect(docked.grid[2]).toBe('clock')
})
- it('moves an app displaced from a full dock into the source slot', () => {
+ it('swaps with an app in a full dock without shifting the dock', () => {
const layout: HomeLayout = {
...defaults,
dock: ['phone', 'messages', 'clock', 'notes'],
}
const docked = moveHomeApp(layout, 'grid', 2, 'dock', 1)
- expect(docked.dock).toEqual(['phone', 'mail', 'messages', 'clock'])
- expect(docked.grid[2]).toBe('notes')
+ expect(docked.dock).toEqual(['phone', 'mail', 'clock', 'notes'])
+ expect(docked.grid[2]).toBe('messages')
})
it('moves shortcuts between the dock and grid independently', () => {
@@ -218,4 +248,114 @@ describe('home layout', () => {
expect(deleted.grid).toContain('phone')
expect(deleteHomePage(deleted, 1)).toBe(deleted)
})
+
+ it('creates, renames, and persists a folder without moving other slots', () => {
+ const folderLayout = createHomeFolder(
+ defaults,
+ 'grid',
+ 2,
+ 'grid',
+ 4,
+ 'folder-work-123456',
+ 'Work',
+ )
+ const folder = folderLayout.grid[4]
+
+ expect(folderLayout.grid[2]).toBeNull()
+ expect(isHomeFolder(folder) && folder.apps).toEqual(['notes', 'mail'])
+ expect(folderLayout.grid[0]).toBe('phone')
+
+ const renamed = renameHomeFolder(
+ folderLayout,
+ 'folder-work-123456',
+ ' Dienstprogramme ',
+ )
+ expect(getHomeFolder(renamed, 'folder-work-123456')?.name).toBe(
+ 'Dienstprogramme',
+ )
+
+ const parsed = parseHomeLayout(renamed, defaults, [...installed])
+ expect(parsed.version).toBe(5)
+ expect(getHomeFolder(parsed, 'folder-work-123456')).toEqual({
+ apps: ['notes', 'mail'],
+ id: 'folder-work-123456',
+ name: 'Dienstprogramme',
+ type: 'folder',
+ })
+ })
+
+ it('adds apps to a folder and swaps apps inside its 3x3 pages', () => {
+ const folderLayout = createHomeFolder(
+ defaults,
+ 'grid',
+ 2,
+ 'grid',
+ 4,
+ 'folder-tools-123456',
+ 'Tools',
+ )
+ const expanded = addHomeAppToFolder(
+ folderLayout,
+ 'grid',
+ 3,
+ 'folder-tools-123456',
+ )
+ expect(expanded.grid[3]).toBeNull()
+ expect(getHomeFolder(expanded, 'folder-tools-123456')?.apps).toEqual([
+ 'notes',
+ 'mail',
+ 'clock',
+ ])
+
+ const reordered = moveHomeFolderApp(
+ expanded,
+ 'folder-tools-123456',
+ 2,
+ 0,
+ )
+ expect(getHomeFolder(reordered, 'folder-tools-123456')?.apps).toEqual([
+ 'clock',
+ 'mail',
+ 'notes',
+ ])
+ })
+
+ it('extracts a folder app into an exact gap and dissolves one-app folders', () => {
+ const folderLayout = createHomeFolder(
+ defaults,
+ 'grid',
+ 2,
+ 'grid',
+ 4,
+ 'folder-social-123456',
+ 'Social',
+ )
+ const extracted = extractHomeFolderApp(
+ folderLayout,
+ 'folder-social-123456',
+ 1,
+ 'grid',
+ 12,
+ )
+
+ expect(extracted.grid[12]).toBe('mail')
+ expect(extracted.grid[4]).toBe('notes')
+ expect(getHomeFolder(extracted, 'folder-social-123456')).toBeNull()
+ })
+
+ it('removes hidden apps from folders and dissolves the folder when needed', () => {
+ const folderLayout = createHomeFolder(
+ defaults,
+ 'grid',
+ 2,
+ 'grid',
+ 4,
+ 'folder-mixed-123456',
+ 'Mixed',
+ )
+ const removed = removeHomeApp(folderLayout, 'mail')
+
+ expect(removed.grid[4]).toBe('notes')
+ expect(removed.hidden).toContain('mail')
+ })
})
diff --git a/frontend/src/utils/homeLayout.ts b/frontend/src/utils/homeLayout.ts
index 6f01235..261db2d 100644
--- a/frontend/src/utils/homeLayout.ts
+++ b/frontend/src/utils/homeLayout.ts
@@ -3,23 +3,50 @@ import type { ReorderDirection } from '@/utils/keyboard'
export const HOME_DOCK_CAPACITY = 4
export const HOME_GRID_COLUMNS = 4
-export const HOME_GRID_PAGE_SIZE = 20
+export const HOME_GRID_PAGE_SIZE = 24
+export const HOME_FOLDER_PAGE_SIZE = 9
+export const HOME_FOLDER_NAME_MAX_LENGTH = 32
export const MAX_HOME_GRID_PAGES = 5
+const LEGACY_HOME_GRID_PAGE_SIZE = 20
export type HomeArea = 'dock' | 'grid'
-export type HomeSlot = LaunchablePhoneAppId | null
+
+export type HomeFolder = {
+ apps: LaunchablePhoneAppId[]
+ id: string
+ name: string
+ type: 'folder'
+}
+
+export type HomeItem = HomeFolder | LaunchablePhoneAppId
+export type HomeSlot = HomeItem | null
export type HomeLayout = {
dock: HomeSlot[]
grid: HomeSlot[]
hidden: LaunchablePhoneAppId[]
- version: 3
+ version: 5
+}
+
+export function isHomeFolder(value: unknown): value is HomeFolder {
+ if (!value || typeof value !== 'object') return false
+ const folder = value as Partial
+ return (
+ folder.type === 'folder' &&
+ typeof folder.id === 'string' &&
+ typeof folder.name === 'string' &&
+ Array.isArray(folder.apps)
+ )
}
function isPersistableAppId(value: unknown): value is LaunchablePhoneAppId {
return typeof value === 'string' && /^[a-z0-9][a-z0-9._-]{1,63}$/.test(value)
}
+function isPersistableFolderId(value: unknown): value is string {
+ return typeof value === 'string' && /^folder-[a-z0-9-]{6,80}$/.test(value)
+}
+
function readAppIds(
value: unknown,
availableIds: Set,
@@ -50,74 +77,107 @@ function getGridCapacity(itemCount: number): number {
)
}
+function migrateLegacyGrid(value: unknown): unknown[] {
+ if (!Array.isArray(value)) return []
+ return value.slice(0, LEGACY_HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES)
+}
+
+function cloneItem(item: HomeSlot): HomeSlot {
+ return isHomeFolder(item) ? { ...item, apps: [...item.apps] } : item
+}
+
+function cloneLayout(layout: HomeLayout): HomeLayout {
+ return {
+ dock: layout.dock.map(cloneItem),
+ grid: layout.grid.map(cloneItem),
+ hidden: [...layout.hidden],
+ version: 5,
+ }
+}
+
+function normalizeFolder(folder: HomeFolder): HomeSlot {
+ if (folder.apps.length === 0) return null
+ if (folder.apps.length === 1) return folder.apps[0]
+ return folder
+}
+
+function readItem(
+ value: unknown,
+ availableIds: Set,
+ folderIds: Set,
+): HomeSlot {
+ if (
+ typeof value === 'string' &&
+ availableIds.has(value as LaunchablePhoneAppId)
+ ) {
+ return value as LaunchablePhoneAppId
+ }
+ if (!isHomeFolder(value) || !isPersistableFolderId(value.id)) return null
+ if (folderIds.has(value.id)) return null
+
+ const apps = readAppIds(value.apps, availableIds)
+ if (apps.length === 0) return null
+ if (apps.length === 1) return apps[0]
+ folderIds.add(value.id)
+ return {
+ apps,
+ id: value.id,
+ name: value.name.trim().slice(0, HOME_FOLDER_NAME_MAX_LENGTH),
+ type: 'folder',
+ }
+}
+
function readSlots(
value: unknown,
availableIds: Set,
length: number,
+ folderIds: Set,
): HomeSlot[] {
const slots = createSlots(length)
if (!Array.isArray(value)) return slots
for (let index = 0; index < Math.min(value.length, length); index += 1) {
- const valueId = value[index]
- if (
- typeof valueId === 'string' &&
- availableIds.has(valueId as LaunchablePhoneAppId)
- ) {
- slots[index] = valueId as LaunchablePhoneAppId
- }
+ slots[index] = readItem(value[index], availableIds, folderIds)
}
return slots
}
-function placeInFirstEmptySlot(
- slots: HomeSlot[],
- appId: LaunchablePhoneAppId,
-): void {
+function placeInFirstEmptySlot(slots: HomeSlot[], item: HomeItem): void {
const emptyIndex = slots.indexOf(null)
if (emptyIndex !== -1) {
- slots[emptyIndex] = appId
+ slots[emptyIndex] = cloneItem(item)
return
}
slots.push(...createSlots(HOME_GRID_PAGE_SIZE))
- slots[slots.length - HOME_GRID_PAGE_SIZE] = appId
+ slots[slots.length - HOME_GRID_PAGE_SIZE] = cloneItem(item)
}
-function insertIntoSlot(
- slots: HomeSlot[],
- targetIndex: number,
- appId: LaunchablePhoneAppId,
-): HomeSlot {
- if (slots[targetIndex] === null) {
- slots[targetIndex] = appId
- return null
- }
+function itemContainsApp(item: HomeSlot, appId: LaunchablePhoneAppId): boolean {
+ return isHomeFolder(item) ? item.apps.includes(appId) : item === appId
+}
- const emptyAfter = slots.indexOf(null, targetIndex + 1)
- if (emptyAfter !== -1) {
- for (let index = emptyAfter; index > targetIndex; index -= 1) {
- slots[index] = slots[index - 1]
- }
- slots[targetIndex] = appId
- return null
+function folderLocation(
+ layout: HomeLayout,
+ folderId: string,
+): { area: HomeArea; index: number } | null {
+ for (const area of ['grid', 'dock'] as const) {
+ const index = layout[area].findIndex(
+ (item) => isHomeFolder(item) && item.id === folderId,
+ )
+ if (index !== -1) return { area, index }
}
+ return null
+}
- const emptyBefore = slots.lastIndexOf(null, targetIndex - 1)
- if (emptyBefore !== -1) {
- for (let index = emptyBefore; index < targetIndex; index += 1) {
- slots[index] = slots[index + 1]
- }
- slots[targetIndex] = appId
- return null
- }
-
- const displacedApp = slots.at(-1) ?? null
- for (let index = slots.length - 1; index > targetIndex; index -= 1) {
- slots[index] = slots[index - 1]
- }
- slots[targetIndex] = appId
- return displacedApp
+export function getHomeFolder(
+ layout: HomeLayout,
+ folderId: string,
+): HomeFolder | null {
+ const location = folderLocation(layout, folderId)
+ if (!location) return null
+ const item = layout[location.area][location.index]
+ return isHomeFolder(item) ? item : null
}
export function createDefaultHomeLayout(
@@ -140,7 +200,7 @@ export function createDefaultHomeLayout(
dock[index] = id
}
- return { dock, grid, hidden: [], version: 3 }
+ return { dock, grid, hidden: [], version: 5 }
}
export function parseHomeLayout(
@@ -152,18 +212,27 @@ export function parseHomeLayout(
const source = value as Partial>
const availableIds = new Set(installedIds)
- if (source.version === 3) {
+ if (source.version === 3 || source.version === 4 || source.version === 5) {
for (const collection of [source.dock, source.grid, source.hidden]) {
if (!Array.isArray(collection)) continue
- for (const appId of collection) {
- if (isPersistableAppId(appId)) availableIds.add(appId)
+ for (const item of collection) {
+ if (isPersistableAppId(item)) availableIds.add(item)
+ if (source.version === 5 && isHomeFolder(item)) {
+ for (const appId of item.apps) {
+ if (isPersistableAppId(appId)) availableIds.add(appId)
+ }
+ }
}
}
}
const hidden = readAppIds(source.hidden, availableIds)
const hiddenIds = new Set(hidden)
- const persistedGridLength = Array.isArray(source.grid)
- ? Math.min(source.grid.length, HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES)
+ const persistedGrid =
+ source.version === 2 || source.version === 3
+ ? migrateLegacyGrid(source.grid)
+ : source.grid
+ const persistedGridLength = Array.isArray(persistedGrid)
+ ? Math.min(persistedGrid.length, HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES)
: 0
const gridLength = Math.max(
defaults.grid.length,
@@ -172,9 +241,15 @@ export function parseHomeLayout(
let grid: HomeSlot[]
let dock: HomeSlot[]
- if (source.version === 2 || source.version === 3) {
- grid = readSlots(source.grid, availableIds, gridLength)
- dock = readSlots(source.dock, availableIds, HOME_DOCK_CAPACITY)
+ if (
+ source.version === 2 ||
+ source.version === 3 ||
+ source.version === 4 ||
+ source.version === 5
+ ) {
+ const folderIds = new Set()
+ grid = readSlots(persistedGrid, availableIds, gridLength, folderIds)
+ dock = readSlots(source.dock, availableIds, HOME_DOCK_CAPACITY, folderIds)
} else {
grid = createSlots(gridLength)
for (const id of readAppIds(source.grid, availableIds)) {
@@ -188,35 +263,53 @@ export function parseHomeLayout(
}
}
- grid = grid.map((id) => (id && !hiddenIds.has(id) ? id : null))
- dock = dock.map((id) => (id && !hiddenIds.has(id) ? id : null))
- const placedIds = new Set(
- [...grid, ...dock, ...hidden].filter(
- (id): id is LaunchablePhoneAppId => id !== null,
- ),
- )
-
- for (const id of defaults.grid) {
- if (id && !placedIds.has(id)) {
- placeInFirstEmptySlot(grid, id)
- placedIds.add(id)
+ const removeHidden = (item: HomeSlot): HomeSlot => {
+ if (typeof item === 'string') return hiddenIds.has(item) ? null : item
+ if (!isHomeFolder(item)) return null
+ return normalizeFolder({
+ ...item,
+ apps: item.apps.filter((appId) => !hiddenIds.has(appId)),
+ })
+ }
+ grid = grid.map(removeHidden)
+ dock = dock.map(removeHidden)
+ const placedIds = new Set(hidden)
+ for (const item of [...grid, ...dock]) {
+ if (typeof item === 'string') placedIds.add(item)
+ if (isHomeFolder(item)) {
+ for (const appId of item.apps) placedIds.add(appId)
}
}
- return { dock, grid, hidden, version: 3 }
+ for (const item of defaults.grid) {
+ if (typeof item === 'string' && !placedIds.has(item)) {
+ placeInFirstEmptySlot(grid, item)
+ placedIds.add(item)
+ }
+ }
+
+ return { dock, grid, hidden, version: 5 }
}
export function removeHomeApp(
layout: HomeLayout,
appId: LaunchablePhoneAppId,
): HomeLayout {
+ const removeFromItem = (item: HomeSlot): HomeSlot => {
+ if (item === appId) return null
+ if (!isHomeFolder(item)) return item
+ return normalizeFolder({
+ ...item,
+ apps: item.apps.filter((folderAppId) => folderAppId !== appId),
+ })
+ }
return {
- dock: layout.dock.map((id) => (id === appId ? null : id)),
- grid: layout.grid.map((id) => (id === appId ? null : id)),
+ dock: layout.dock.map(removeFromItem),
+ grid: layout.grid.map(removeFromItem),
hidden: layout.hidden.includes(appId)
? [...layout.hidden]
: [...layout.hidden, appId],
- version: 3,
+ version: 5,
}
}
@@ -224,14 +317,19 @@ export function restoreHomeApp(
layout: HomeLayout,
appId: LaunchablePhoneAppId,
): HomeLayout {
- if (layout.grid.includes(appId) || layout.dock.includes(appId)) return layout
- const grid = [...layout.grid]
+ if (
+ layout.grid.some((item) => itemContainsApp(item, appId)) ||
+ layout.dock.some((item) => itemContainsApp(item, appId))
+ ) {
+ return layout
+ }
+ const grid = layout.grid.map(cloneItem)
placeInFirstEmptySlot(grid, appId)
return {
- dock: [...layout.dock],
+ dock: layout.dock.map(cloneItem),
grid,
hidden: layout.hidden.filter((id) => id !== appId),
- version: 3,
+ version: 5,
}
}
@@ -241,10 +339,10 @@ export function addHomePage(layout: HomeLayout): HomeLayout {
}
return {
- dock: [...layout.dock],
- grid: [...layout.grid, ...createSlots(HOME_GRID_PAGE_SIZE)],
+ dock: layout.dock.map(cloneItem),
+ grid: [...layout.grid.map(cloneItem), ...createSlots(HOME_GRID_PAGE_SIZE)],
hidden: [...layout.hidden],
- version: 3,
+ version: 5,
}
}
@@ -253,20 +351,20 @@ export function deleteHomePage(layout: HomeLayout, page: number): HomeLayout {
if (pageCount <= 1 || page < 1 || page > pageCount) return layout
const pageStart = (page - 1) * HOME_GRID_PAGE_SIZE
- const grid = [...layout.grid]
- const removedApps = grid
+ const grid = layout.grid.map(cloneItem)
+ const removedItems = grid
.splice(pageStart, HOME_GRID_PAGE_SIZE)
- .filter((appId): appId is LaunchablePhoneAppId => appId !== null)
- if (removedApps.length > grid.filter((appId) => appId === null).length) {
+ .filter((item): item is HomeItem => item !== null)
+ if (removedItems.length > grid.filter((item) => item === null).length) {
return layout
}
- for (const appId of removedApps) placeInFirstEmptySlot(grid, appId)
+ for (const item of removedItems) placeInFirstEmptySlot(grid, item)
return {
- dock: [...layout.dock],
+ dock: layout.dock.map(cloneItem),
grid,
hidden: [...layout.hidden],
- version: 3,
+ version: 5,
}
}
@@ -277,17 +375,12 @@ export function moveHomeApp(
to: HomeArea,
targetIndex: number,
): HomeLayout {
- const next: HomeLayout = {
- dock: [...layout.dock],
- grid: [...layout.grid],
- hidden: [...layout.hidden],
- version: 3,
- }
+ const next = cloneLayout(layout)
const source = next[from]
const target = next[to]
- const appId = source[sourceIndex]
+ const item = source[sourceIndex]
if (
- !appId ||
+ !item ||
sourceIndex < 0 ||
sourceIndex >= source.length ||
targetIndex < 0 ||
@@ -296,14 +389,139 @@ export function moveHomeApp(
return layout
}
- if (from === to) {
- if (sourceIndex === targetIndex) return layout
- source[sourceIndex] = null
- insertIntoSlot(source, targetIndex, appId)
- return next
+ if (from === to && sourceIndex === targetIndex) return layout
+ source[sourceIndex] = cloneItem(target[targetIndex])
+ target[targetIndex] = item
+ return next
+}
+
+export function createHomeFolder(
+ layout: HomeLayout,
+ from: HomeArea,
+ sourceIndex: number,
+ to: HomeArea,
+ targetIndex: number,
+ folderId: string,
+ name: string,
+): HomeLayout {
+ if (!isPersistableFolderId(folderId)) return layout
+ if (folderLocation(layout, folderId)) return layout
+ const sourceItem = layout[from][sourceIndex]
+ const targetItem = layout[to][targetIndex]
+ if (
+ typeof sourceItem !== 'string' ||
+ typeof targetItem !== 'string' ||
+ (from === to && sourceIndex === targetIndex)
+ ) {
+ return layout
}
- source[sourceIndex] = insertIntoSlot(target, targetIndex, appId)
+ const next = cloneLayout(layout)
+ next[from][sourceIndex] = null
+ next[to][targetIndex] = {
+ apps: [targetItem, sourceItem],
+ id: folderId,
+ name: name.trim().slice(0, HOME_FOLDER_NAME_MAX_LENGTH),
+ type: 'folder',
+ }
+ return next
+}
+
+export function addHomeAppToFolder(
+ layout: HomeLayout,
+ from: HomeArea,
+ sourceIndex: number,
+ folderId: string,
+): HomeLayout {
+ const sourceItem = layout[from][sourceIndex]
+ const location = folderLocation(layout, folderId)
+ if (typeof sourceItem !== 'string' || !location) return layout
+ if (location.area === from && location.index === sourceIndex) return layout
+
+ const next = cloneLayout(layout)
+ const folder = next[location.area][location.index]
+ if (!isHomeFolder(folder)) return layout
+ next[from][sourceIndex] = null
+ folder.apps.push(sourceItem)
+ return next
+}
+
+export function renameHomeFolder(
+ layout: HomeLayout,
+ folderId: string,
+ name: string,
+): HomeLayout {
+ const location = folderLocation(layout, folderId)
+ if (!location) return layout
+ const next = cloneLayout(layout)
+ const folder = next[location.area][location.index]
+ if (!isHomeFolder(folder)) return layout
+ const nextName = name.trim().slice(0, HOME_FOLDER_NAME_MAX_LENGTH)
+ if (folder.name === nextName) return layout
+ folder.name = nextName
+ return next
+}
+
+export function moveHomeFolderApp(
+ layout: HomeLayout,
+ folderId: string,
+ sourceIndex: number,
+ targetIndex: number,
+): HomeLayout {
+ const location = folderLocation(layout, folderId)
+ if (!location) return layout
+ const folder = layout[location.area][location.index]
+ if (
+ !isHomeFolder(folder) ||
+ sourceIndex < 0 ||
+ sourceIndex >= folder.apps.length ||
+ targetIndex < 0 ||
+ targetIndex >= folder.apps.length ||
+ sourceIndex === targetIndex
+ ) {
+ return layout
+ }
+
+ const next = cloneLayout(layout)
+ const nextFolder = next[location.area][location.index]
+ if (!isHomeFolder(nextFolder)) return layout
+ const sourceApp = nextFolder.apps[sourceIndex]
+ nextFolder.apps[sourceIndex] = nextFolder.apps[targetIndex]
+ nextFolder.apps[targetIndex] = sourceApp
+ return next
+}
+
+export function extractHomeFolderApp(
+ layout: HomeLayout,
+ folderId: string,
+ sourceIndex: number,
+ to: HomeArea,
+ targetIndex: number,
+): HomeLayout {
+ const location = folderLocation(layout, folderId)
+ const target = layout[to][targetIndex]
+ if (
+ !location ||
+ target !== null ||
+ (location.area === to && location.index === targetIndex)
+ ) {
+ return layout
+ }
+ const folder = layout[location.area][location.index]
+ if (
+ !isHomeFolder(folder) ||
+ sourceIndex < 0 ||
+ sourceIndex >= folder.apps.length
+ ) {
+ return layout
+ }
+
+ const next = cloneLayout(layout)
+ const nextFolder = next[location.area][location.index]
+ if (!isHomeFolder(nextFolder)) return layout
+ const [appId] = nextFolder.apps.splice(sourceIndex, 1)
+ next[to][targetIndex] = appId
+ next[location.area][location.index] = normalizeFolder(nextFolder)
return next
}
diff --git a/frontend/src/utils/widgetLayout.test.ts b/frontend/src/utils/widgetLayout.test.ts
index 38f80a3..1b7cd2e 100644
--- a/frontend/src/utils/widgetLayout.test.ts
+++ b/frontend/src/utils/widgetLayout.test.ts
@@ -30,7 +30,7 @@ describe('widget layout', () => {
instance.id.startsWith('wallet-'),
)
- expect(added).toMatchObject({ column: 0, page: 2, row: 0, size: 'small' })
+ expect(added).toMatchObject({ column: 0, page: 1, row: 4, size: 'small' })
})
it('moves a widget and reflows a displaced neighbor', () => {
diff --git a/frontend/src/utils/widgetLayout.ts b/frontend/src/utils/widgetLayout.ts
index 5b6dc54..6869be5 100644
--- a/frontend/src/utils/widgetLayout.ts
+++ b/frontend/src/utils/widgetLayout.ts
@@ -9,7 +9,7 @@ import type {
import type { ReorderDirection } from '@/utils/keyboard'
export const WIDGET_GRID_COLUMNS = 4
-export const WIDGET_HOME_ROWS = 5
+export const WIDGET_HOME_ROWS = 6
export const WIDGET_PAGE_ROWS = 10
const MAX_WIDGET_HOME_PAGES = 5
diff --git a/frontend/src/views/SpringboardView.vue b/frontend/src/views/SpringboardView.vue
index 03b7cb0..be9eb61 100644
--- a/frontend/src/views/SpringboardView.vue
+++ b/frontend/src/views/SpringboardView.vue
@@ -4,6 +4,8 @@ import { kButton, kGlass, kList, kListItem, kSheet } from 'konsta/vue'
import { computed, nextTick, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
+import HomeFolderIcon from '@/components/HomeFolderIcon.vue'
+import HomeFolderOverlay from '@/components/HomeFolderOverlay.vue'
import SpringboardWidgetGrid from '@/components/SpringboardWidgetGrid.vue'
import WidgetConfigSheet from '@/components/WidgetConfigSheet.vue'
import WidgetPickerSheet from '@/components/WidgetPickerSheet.vue'
@@ -16,10 +18,14 @@ import type { LaunchablePhoneAppId } from '@/types/apps'
import type { WidgetKind, WidgetSettings, WidgetSize } from '@/types/widgets'
import {
deleteHomePage as previewHomePageDelete,
+ getHomeFolder,
HOME_GRID_PAGE_SIZE,
homeKeyboardTarget,
+ isHomeFolder,
MAX_HOME_GRID_PAGES,
type HomeArea,
+ type HomeFolder,
+ type HomeItem,
} from '@/utils/homeLayout'
import type { ReorderDirection } from '@/utils/keyboard'
import {
@@ -37,6 +43,7 @@ const APP_LIBRARY_CATEGORIES: PhoneAppCategory[] = [
'utilities',
'shopping',
]
+const HOME_FOLDER_HOVER_DURATION = 900
const phone = usePhoneStore()
const appStore = useAppStoreStore()
const widgets = useWidgetsStore()
@@ -63,6 +70,9 @@ const draggingHomeApp = ref<{
area: HomeArea
index: number
} | null>(null)
+const openedFolderId = ref(null)
+const renameFolderOnOpenId = ref(null)
+const folderHoverTarget = ref('')
let pointerStart = 0
let pointerStartY = 0
let pointerStartedAt = 0
@@ -75,6 +85,7 @@ let lastWidgetPointer: { clientX: number; clientY: number } | null = null
let edgePageTimer: number | undefined
let edgePageDirection = 0
let edgePageLocked = false
+let folderHoverTimer: number | undefined
const installedApps = computed(() =>
PHONE_APPS.filter((app) => appStore.isInstalled(app.id)),
@@ -87,17 +98,25 @@ const hiddenApps = computed(() =>
appStore.homeLayout.hidden.includes(app.id),
),
)
-const gridEntries = computed(() =>
- appStore.homeLayout.grid
- .map((id, sourceIndex) => ({
- app: id ? (installedAppsById.value.get(id) ?? null) : null,
+const gridSlots = computed(() => {
+ const appOccurrences = new Map()
+ return appStore.homeLayout.grid.map((item, sourceIndex) => {
+ const appId = typeof item === 'string' ? item : null
+ const folder = isHomeFolder(item) ? item : null
+ const occurrence = appId ? (appOccurrences.get(appId) ?? 0) : 0
+ if (appId) appOccurrences.set(appId, occurrence + 1)
+ return {
+ app: appId ? (installedAppsById.value.get(appId) ?? null) : null,
+ folder,
+ renderKey: folder
+ ? `grid-folder-${folder.id}`
+ : appId
+ ? `grid-app-${appId}-${occurrence}`
+ : `grid-empty-${sourceIndex}`,
sourceIndex,
- }))
- .filter(
- (entry): entry is { app: PhoneAppDefinition; sourceIndex: number } =>
- entry.app !== null,
- ),
-)
+ }
+ })
+})
const previewWidgetLayout = computed(() => {
const preview = widgetDragPreview.value
return preview
@@ -111,9 +130,14 @@ const previewWidgetLayout = computed(() => {
: widgets.layout
})
const appPages = computed(() => {
- const entries = [...gridEntries.value]
+ const slots = [...gridSlots.value]
const pages: Array<{
- cells: Array<{ app: PhoneAppDefinition; sourceIndex: number } | null>
+ cells: Array<{
+ app: PhoneAppDefinition | null
+ folder: HomeFolder | null
+ renderKey: string
+ sourceIndex: number | null
+ }>
page: number
}> = []
const maxWidgetPage = Math.max(
@@ -126,8 +150,8 @@ const appPages = computed(() => {
)
const lastHomePage = Math.max(maxWidgetPage, persistedHomePages)
let page = 1
- let entryIndex = 0
- while (entryIndex < entries.length || page <= lastHomePage) {
+ let slotIndex = 0
+ while (slotIndex < slots.length || page <= lastHomePage) {
const occupied = widgetOccupiedCells(
previewWidgetLayout.value.instances,
page,
@@ -137,21 +161,40 @@ const appPages = computed(() => {
occupied.add(cell)
}
}
- const cells = Array.from<{
- app: PhoneAppDefinition
- sourceIndex: number
- } | null>({ length: HOME_GRID_PAGE_SIZE }).fill(null)
+ const cells: Array<{
+ app: PhoneAppDefinition | null
+ folder: HomeFolder | null
+ renderKey: string
+ sourceIndex: number | null
+ }> = Array(HOME_GRID_PAGE_SIZE)
+ .fill(null)
+ .map((_, cell) => ({
+ app: null,
+ folder: null,
+ renderKey: `grid-occupied-${page}-${cell}`,
+ sourceIndex: null,
+ }))
for (let cell = 0; cell < HOME_GRID_PAGE_SIZE; cell += 1) {
- if (occupied.has(cell) || entryIndex >= entries.length) continue
- cells[cell] = entries[entryIndex]
- entryIndex += 1
+ if (occupied.has(cell) || slotIndex >= slots.length) continue
+ cells[cell] = slots[slotIndex]
+ slotIndex += 1
}
pages.push({ cells, page })
page += 1
}
return pages.length
? pages
- : [{ cells: Array(HOME_GRID_PAGE_SIZE).fill(null), page: 1 }]
+ : [
+ {
+ cells: Array.from({ length: HOME_GRID_PAGE_SIZE }, (_, sourceIndex) => ({
+ app: null,
+ folder: null,
+ renderKey: `grid-empty-${sourceIndex}`,
+ sourceIndex,
+ })),
+ page: 1,
+ },
+ ]
})
const pageCount = computed(() => appPages.value.length + 2)
const libraryPage = computed(() => pageCount.value - 1)
@@ -183,9 +226,27 @@ const canDeleteCurrentPage = computed(() => {
)
})
const dockSlots = computed(() =>
- appStore.homeLayout.dock.map((id) =>
- id ? (installedAppsById.value.get(id) ?? null) : null,
- ),
+ appStore.homeLayout.dock.map((item, index) => ({
+ app:
+ typeof item === 'string'
+ ? (installedAppsById.value.get(item) ?? null)
+ : null,
+ folder: isHomeFolder(item) ? item : null,
+ index,
+ })),
+)
+const openedFolder = computed(() =>
+ openedFolderId.value
+ ? getHomeFolder(appStore.homeLayout, openedFolderId.value)
+ : null,
+)
+const openedFolderApps = computed(() =>
+ openedFolder.value
+ ? openedFolder.value.apps.flatMap((appId, index) => {
+ const app = installedAppsById.value.get(appId)
+ return app ? [{ app, index }] : []
+ })
+ : [],
)
const filteredApps = computed(() => {
const query = searchQuery.value.trim().toLocaleLowerCase(phone.lang)
@@ -567,27 +628,151 @@ async function saveWidgetConfig(
if (configured) changePage(configured.page)
}
-function targetHomeIndex(page: number, cell: number): number {
- return Math.min(
- Math.max(0, appStore.homeLayout.grid.length - 1),
- (page - 1) * HOME_GRID_PAGE_SIZE + cell,
- )
-}
-
function startHomeDrag(area: HomeArea, index: number): void {
draggingHomeApp.value = { area, index }
}
+function clearFolderHover(): void {
+ if (folderHoverTimer !== undefined) window.clearTimeout(folderHoverTimer)
+ folderHoverTimer = undefined
+ folderHoverTarget.value = ''
+}
+
+function queueFolderHover(event: PointerEvent): void {
+ const dragged = draggingHomeApp.value
+ if (!dragged) return
+ const sourceItem = appStore.homeLayout[dragged.area][dragged.index]
+ if (typeof sourceItem !== 'string') {
+ clearFolderHover()
+ return
+ }
+
+ const targetElement = document
+ .elementsFromPoint(event.clientX, event.clientY)
+ .map((element) => element.closest('[data-home-index]'))
+ .find(
+ (element) =>
+ element && !element.closest('.app-icon-item--dragging'),
+ )
+ const area = targetElement?.dataset.homeArea as HomeArea | undefined
+ const targetIndex = Number(targetElement?.dataset.homeIndex)
+ if (
+ (area !== 'grid' && area !== 'dock') ||
+ !Number.isInteger(targetIndex) ||
+ (area === dragged.area && targetIndex === dragged.index)
+ ) {
+ clearFolderHover()
+ return
+ }
+ const targetItem = appStore.homeLayout[area][targetIndex]
+ if (typeof targetItem !== 'string' && !isHomeFolder(targetItem)) {
+ clearFolderHover()
+ return
+ }
+
+ const targetKey = `${area}:${targetIndex}`
+ if (folderHoverTarget.value === targetKey) return
+ clearFolderHover()
+ folderHoverTarget.value = targetKey
+ folderHoverTimer = window.setTimeout(() => {
+ const currentDrag = draggingHomeApp.value
+ if (!currentDrag || folderHoverTarget.value !== targetKey) return
+ const currentSource = appStore.homeLayout[currentDrag.area][currentDrag.index]
+ const currentTarget = appStore.homeLayout[area][targetIndex]
+ if (typeof currentSource !== 'string') return
+
+ let folderId: string | null = null
+ if (typeof currentTarget === 'string') {
+ folderId = appStore.createHomeFolder(
+ currentDrag.area,
+ currentDrag.index,
+ area,
+ targetIndex,
+ phone.t('Home.folders.defaultName'),
+ )
+ if (folderId) renameFolderOnOpenId.value = folderId
+ } else if (isHomeFolder(currentTarget)) {
+ folderId = appStore.addHomeAppToFolder(
+ currentDrag.area,
+ currentDrag.index,
+ currentTarget.id,
+ )
+ ? currentTarget.id
+ : null
+ }
+ if (folderId) {
+ draggingHomeApp.value = null
+ openedFolderId.value = folderId
+ }
+ clearFolderHover()
+ }, HOME_FOLDER_HOVER_DURATION)
+}
+
function moveHomeDrag(event: PointerEvent): void {
if (draggingHomeApp.value?.area === 'grid') {
queueEdgePageTurn(event, 'app')
}
+ queueFolderHover(event)
+}
+
+async function animateHomeItemDrop(
+ item: HomeItem,
+ area: HomeArea,
+ index: number,
+ from: DOMRect,
+): Promise {
+ await nextTick()
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
+
+ const appElement = Array.from(
+ document.querySelectorAll('[data-home-item-key]'),
+ ).find(
+ (element) =>
+ element.dataset.homeItemKey ===
+ (isHomeFolder(item) ? item.id : `app-${item}`) &&
+ element.dataset.homeArea === area &&
+ Number(element.dataset.homeIndex) === index,
+ )
+ if (!appElement) return
+
+ const target = appElement.getBoundingClientRect()
+ const offsetX = from.left - target.left
+ const offsetY = from.top - target.top
+ if (Math.hypot(offsetX, offsetY) < 1) return
+
+ appElement.style.setProperty('--home-app-drop-x', `${offsetX}px`)
+ appElement.style.setProperty('--home-app-drop-y', `${offsetY}px`)
+ let cleanedUp = false
+ const cleanUpAnimation = (event: AnimationEvent): void => {
+ if (
+ event.target !== appElement ||
+ event.animationName !== 'home-app-drop-settle'
+ ) {
+ return
+ }
+ if (cleanedUp) return
+ cleanedUp = true
+ appElement.classList.remove('app-icon-item--drop-settling')
+ appElement.style.removeProperty('--home-app-drop-x')
+ appElement.style.removeProperty('--home-app-drop-y')
+ appElement.removeEventListener('animationend', cleanUpAnimation)
+ appElement.removeEventListener('animationcancel', cleanUpAnimation)
+ }
+ appElement.addEventListener('animationend', cleanUpAnimation)
+ appElement.addEventListener('animationcancel', cleanUpAnimation)
+ appElement.classList.add('app-icon-item--drop-settling')
}
function finishHomeDrag(event: PointerEvent): void {
clearEdgePageTurn()
+ clearFolderHover()
const dragged = draggingHomeApp.value
if (!dragged) return
+ const draggedItem = appStore.homeLayout[dragged.area][dragged.index]
+ const draggedElement = (event.currentTarget as HTMLElement | null)?.closest(
+ '.app-icon-item',
+ )
+ const dropOrigin = draggedElement?.getBoundingClientRect()
const target = document
.elementsFromPoint(event.clientX, event.clientY)
.find(
@@ -619,15 +804,23 @@ function finishHomeDrag(event: PointerEvent): void {
}
const area = (targetItem?.dataset.homeArea ??
targetArea?.dataset.homeArea) as HomeArea | undefined
+ let dropArea = dragged.area
+ let dropIndex = dragged.index
if ((area === 'grid' || area === 'dock') && targetItem) {
const targetIndex = Number.parseInt(targetItem.dataset.homeIndex ?? '', 10)
appStore.moveHomeApp(dragged.area, dragged.index, area, targetIndex)
+ dropArea = area
+ dropIndex = targetIndex
}
draggingHomeApp.value = null
+ if (draggedItem && dropOrigin) {
+ void animateHomeItemDrop(draggedItem, dropArea, dropIndex, dropOrigin)
+ }
}
function stopHomeDrag(): void {
clearEdgePageTurn()
+ clearFolderHover()
draggingHomeApp.value = null
}
@@ -636,6 +829,11 @@ function reorderHomeApp(
sourceIndex: number,
direction: ReorderDirection,
): void {
+ const item = appStore.homeLayout[area][sourceIndex]
+ const appElement = document.querySelector(
+ `[data-home-area="${area}"][data-home-index="${sourceIndex}"]`,
+ )
+ const moveOrigin = appElement?.getBoundingClientRect()
const targetIndex = homeKeyboardTarget(
appStore.homeLayout,
area,
@@ -644,6 +842,9 @@ function reorderHomeApp(
)
if (targetIndex === null) return
appStore.moveHomeApp(area, sourceIndex, area, targetIndex)
+ if (item && moveOrigin) {
+ void animateHomeItemDrop(item, area, targetIndex, moveOrigin)
+ }
}
async function addHomePage(): Promise {
@@ -676,6 +877,90 @@ function restoreHomeApp(appId: LaunchablePhoneAppId): void {
appStore.restoreHomeApp(appId)
}
+function folderPreviewApps(folder: HomeFolder): PhoneAppDefinition[] {
+ const apps: PhoneAppDefinition[] = []
+ for (const appId of folder.apps) {
+ const app = installedAppsById.value.get(appId)
+ if (app) apps.push(app)
+ }
+ return apps
+}
+
+function openFolder(folderId: string): void {
+ pageTransitioning.value = false
+ openedFolderId.value = folderId
+}
+
+function closeFolder(): void {
+ openedFolderId.value = null
+ renameFolderOnOpenId.value = null
+}
+
+function renameOpenedFolder(name: string): void {
+ if (!openedFolderId.value) return
+ appStore.renameHomeFolder(openedFolderId.value, name)
+}
+
+function moveOpenedFolderApp(sourceIndex: number, targetIndex: number): void {
+ if (!openedFolderId.value) return
+ appStore.moveHomeFolderApp(openedFolderId.value, sourceIndex, targetIndex)
+}
+
+function extractOpenedFolderApp(
+ sourceIndex: number,
+ event: PointerEvent,
+): void {
+ const folder = openedFolder.value
+ if (!folder) return
+ const appId = folder.apps[sourceIndex]
+ const sourceElement = (event.currentTarget as HTMLElement | null)?.closest(
+ '.app-icon-item',
+ )
+ const sourceBounds = sourceElement?.getBoundingClientRect()
+ const candidates = Array.from(
+ document.querySelectorAll('[data-home-index][data-home-area]'),
+ ).filter((element) => {
+ const area = element.dataset.homeArea as HomeArea
+ const index = Number(element.dataset.homeIndex)
+ return (
+ (area === 'grid' || area === 'dock') &&
+ Number.isInteger(index) &&
+ appStore.homeLayout[area][index] === null
+ )
+ })
+ const target = candidates.reduce((closest, candidate) => {
+ if (!closest) return candidate
+ const bounds = candidate.getBoundingClientRect()
+ const closestBounds = closest.getBoundingClientRect()
+ const distance = Math.hypot(
+ event.clientX - (bounds.left + bounds.width / 2),
+ event.clientY - (bounds.top + bounds.height / 2),
+ )
+ const closestDistance = Math.hypot(
+ event.clientX - (closestBounds.left + closestBounds.width / 2),
+ event.clientY - (closestBounds.top + closestBounds.height / 2),
+ )
+ return distance < closestDistance ? candidate : closest
+ }, null)
+ if (!target) return
+
+ const area = target.dataset.homeArea as HomeArea
+ const targetIndex = Number(target.dataset.homeIndex)
+ if (
+ !appStore.extractHomeFolderApp(
+ folder.id,
+ sourceIndex,
+ area,
+ targetIndex,
+ )
+ ) {
+ return
+ }
+ if (sourceBounds) {
+ void animateHomeItemDrop(appId, area, targetIndex, sourceBounds)
+ }
+}
+
function clearSearch(): void {
searchQuery.value = ''
searchFocused.value = false
@@ -690,6 +975,9 @@ function openAllApps(): void {
watch(isEditablePage, (visible) => {
if (!visible) editMode.value = false
})
+watch(openedFolder, (folder) => {
+ if (!folder) closeFolder()
+})
@@ -700,6 +988,7 @@ watch(isEditablePage, (visible) => {
{
'springboard--dragging': dragging,
'springboard--editing': editMode,
+ 'springboard--folder-open': openedFolder !== null,
'springboard--widget-dragging': draggingWidgetId !== null,
},
]"
@@ -763,15 +1052,21 @@ watch(isEditablePage, (visible) => {
data-home-area="grid"
>
{
@remove="removeHomeApp(cell.app.id)"
@reorder="reorderHomeApp('grid', cell.sourceIndex, $event)"
/>
+
@@ -927,7 +1244,7 @@ watch(isEditablePage, (visible) => {
{
{
data-home-area="dock"
>
+
@@ -1026,6 +1374,22 @@ watch(isEditablePage, (visible) => {
+
+
+
+
{
).length,
}
}
+ const bootstrapDeviceData =
+ testScenario === 'easyshare-full' &&
+ !deviceData.apps?.payload?.homeLayout
+ ? {
+ ...deviceData,
+ apps: {
+ ...deviceData.apps,
+ payload: {
+ ...deviceData.apps.payload,
+ homeLayout: {
+ dock: ['phone', 'messages', 'camera', 'clock'],
+ grid: [
+ {
+ apps: [
+ 'mail',
+ 'notes',
+ 'photos',
+ 'app-store',
+ 'settings',
+ 'map',
+ 'citymarkt',
+ 'local-pages',
+ 'calendar',
+ 'darkchat',
+ ],
+ id: 'folder-browser-demo-123456',
+ name: 'Los Santos',
+ type: 'folder',
+ },
+ 'banking',
+ 'weather',
+ 'billing',
+ ],
+ hidden: [],
+ version: 5,
+ },
+ },
+ },
+ }
+ : deviceData
response.json({
success: true,
data: {
@@ -7670,7 +7710,7 @@ app.post('/api/:endpoint', (request, response) => {
}
: deviceData.appAuth,
}
- : deviceData,
+ : bootstrapDeviceData,
imei: '356938035643809',
name: 'Personal iFruit Phone',
sim: mockSim,
diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua
index af7e0b3..2ed4577 100644
--- a/sky_phone/config/locales/en.lua
+++ b/sky_phone/config/locales/en.lua
@@ -106,6 +106,7 @@ Locales["en"] = {
Home = {
appLibrary = "App Library", appLibrarySearch = "Search apps", allApps = "All Apps", apps = "Apps",
dock = "Dock", noApps = "No apps found", removeApp = "Remove {app} from Home Screen", addToHome = "Add {app} to Home Screen", addPage = "Add Home Screen page", deletePage = "Delete current Home Screen page", removedFromHome = "Removed from Home Screen", page = "Page", pages = "Home screen pages",
+ folders = { defaultName = "Folder", close = "Close folder", rename = "Rename Folder", name = "Folder name", pages = "Folder pages" },
groups = {
suggestions = "Suggestions", recentlyAdded = "Recently Added", games = "Games",
productivity = "Productivity", shopping = "Shopping", social = "Social Networks", utilities = "Utilities",