diff --git a/src/lib/components/card.svelte b/src/lib/components/card.svelte index fd94436ab9..c21c90ef55 100644 --- a/src/lib/components/card.svelte +++ b/src/lib/components/card.svelte @@ -20,21 +20,23 @@ }; type ButtonProps = { - isButton: true; + isButton: boolean; href?: never; }; type AnchorProps = { href: string; - isButton?: never; + isButton?: boolean; + external?: boolean; }; + let classes = ''; type $$Props = BaseProps & (ButtonProps | AnchorProps | BaseProps) & BaseCardProps; - export let isDashed = false; - export let isButton = false; + export let isDashed: boolean = false; + export let isButton: boolean = false; export let href: string = null; - let classes = ''; + export let external: boolean = false; export { classes as class }; export let style = ''; export let padding: $$Props['padding'] = 'm'; @@ -45,7 +47,15 @@ {#if href} - + diff --git a/src/lib/elements/forms/inputLine.svelte b/src/lib/elements/forms/inputLine.svelte index 3987b3635b..6bfeb0fe2e 100644 --- a/src/lib/elements/forms/inputLine.svelte +++ b/src/lib/elements/forms/inputLine.svelte @@ -14,6 +14,7 @@ onDeletePoint: (index: number) => void; onChangePoint: (pointIndex: number, coordIndex: number, newValue: number) => void; addLineButton?: Snippet; + disabled?: boolean; }; let { @@ -24,7 +25,8 @@ onAddPoint, onDeletePoint, onChangePoint, - addLineButton + addLineButton, + disabled }: Props = $props(); function isDeleteDisabled(index: number) { @@ -40,6 +42,7 @@ {#each values as value, index} - {@render addLineButton?.()} diff --git a/src/lib/elements/forms/inputPoint.svelte b/src/lib/elements/forms/inputPoint.svelte index 4c0b4ce40f..9a3f70183e 100644 --- a/src/lib/elements/forms/inputPoint.svelte +++ b/src/lib/elements/forms/inputPoint.svelte @@ -10,6 +10,7 @@ deletePoints?: boolean; onDeletePoint?: () => void; disableDelete?: boolean; + disabled?: boolean; onChangePoint: (index: number, newValue: number) => void; } @@ -21,7 +22,8 @@ deletePoints = false, disableDelete = false, onDeletePoint, - onChangePoint + onChangePoint, + disabled }: Props = $props(); @@ -38,6 +40,7 @@ placeholder="Enter value" step={0.0001} value={values[index]} + {disabled} on:change={(e) => onChangePoint(index, Number.parseFloat(`${e.detail}`))} /> {/each} {/if} @@ -45,7 +48,7 @@ diff --git a/src/lib/elements/forms/inputPolygon.svelte b/src/lib/elements/forms/inputPolygon.svelte index c2bcbb4db1..766dfa7d1b 100644 --- a/src/lib/elements/forms/inputPolygon.svelte +++ b/src/lib/elements/forms/inputPolygon.svelte @@ -17,6 +17,7 @@ coordIndex: number, newValue: number ) => void; + disabled?: boolean; }; let { @@ -26,7 +27,8 @@ onAddPoint, onAddLine, onDeletePoint, - onChangePoint + onChangePoint, + disabled }: Props = $props(); @@ -34,6 +36,7 @@ {#each values as value, index} onAddPoint(index)} {nullable} diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/columns.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/columns.svelte new file mode 100644 index 0000000000..69cc26bcbb --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/columns.svelte @@ -0,0 +1,65 @@ + + + + + + + + + + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/empty.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/empty.svelte index 09289f76f7..7a5602eb70 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/empty.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/empty.svelte @@ -8,12 +8,15 @@ Spreadsheet, Typography, FloatingActionBar, - Popover + Popover, + Badge } from '@appwrite.io/pink-svelte'; - import { IconFingerPrint, IconPlus } from '@appwrite.io/pink-icons-svelte'; + import { IconFingerPrint, IconPlus, IconText } from '@appwrite.io/pink-icons-svelte'; import { isSmallViewport, isTabletViewport } from '$lib/stores/viewport'; import type { Column } from '$lib/helpers/types'; - import { expandTabs } from '../table-[table]/store'; + import { SortButton } from '$lib/components'; + import { expandTabs, columnsOrder, columnsWidth, reorderItems } from '../table-[table]/store'; + import { preferences } from '$lib/stores/preferences'; import SpreadsheetContainer from '../table-[table]/layout/spreadsheet.svelte'; import { onDestroy, onMount, tick } from 'svelte'; import { sdk } from '$lib/stores/sdk'; @@ -38,8 +41,62 @@ import Options from './options.svelte'; import { InputSelect, InputText } from '$lib/elements/forms'; import { isCloud, VARS } from '$lib/system'; + import { fade } from 'svelte/transition'; import IconAINotification from './icon/aiNotification.svelte'; + import type { Models } from '@appwrite.io/console'; + + let { + userColumns = [], + userDataRows = [] + }: { + userColumns?: Column[]; + userDataRows?: Models.Row[]; + } = $props(); + + const tableId = page.params.table; + const minimumUserColumnWidth = 168; + + function getUserColumnWidth( + columnId: string, + defaultWidth: number | { min: number } + ): number | { min: number; max?: number } { + const savedWidth = $columnsWidth?.[columnId]; + if (!savedWidth) return defaultWidth; + return savedWidth.resized; + } + + // apply order & width to user columns + const staticUserColumns = $derived.by(() => { + if (!userColumns.length) return []; + + // apply widths to columns + const columnsWithWidths = userColumns.map((column) => { + const defaultWidth = + typeof column.width === 'object' && 'min' in column.width + ? column.width + : typeof column.width === 'number' + ? column.width + : minimumUserColumnWidth; + + return { + ...column, + width: getUserColumnWidth(column.id, defaultWidth), + custom: false, + resizable: false, + draggable: false + }; + }); + + // apply ordering if preferences exist + if ($columnsOrder && $columnsOrder.length > 0) { + return reorderItems(columnsWithWidths, $columnsOrder); + } + + return columnsWithWidths.filter( + (column) => !['$id', '$createdAt', '$updatedAt', 'actions'].includes(column.id) + ); + }); let resizeObserver: ResizeObserver; let spreadsheetContainer: HTMLElement; @@ -48,21 +105,43 @@ let headerElement: HTMLElement | null = null; let rangeOverlayEl: HTMLDivElement | null = null; let fadeBottomOverlayEl: HTMLDivElement | null = null; + let snowFadeBottomOverlayEl: HTMLDivElement | null = null; - let customColumns = $state< - (SuggestedColumnSchema & { elements?: []; isPlaceholder?: boolean })[] - >(Array.from({ length: 7 }, (_, index) => createPlaceholderColumn(index))); + let customColumns = $state( + Array.from({ length: 7 }, (_, index) => createPlaceholderColumn(index)) + ); let showFloatingBar = $state(true); let hasTransitioned = $state(false); let scrollAnimationFrame: number | null = null; let creatingColumns = $state(false); - const baseColProps = { draggable: false, resizable: false }; + let selectedColumnId = $state(null); + let previousColumnId = $state(null); + let selectedColumnName = $state(null); + + let showHeadTooltip = $state(true); + let isInlineEditing = $state(false); + // let tooltipTopPosition = $state(50); + let triggerColumnId = $state(null); + let hoveredColumnId = $state(null); + + // for deleting a column + undo + let undoTimer: ReturnType | null = $state(null); + let columnBeingDeleted: (SuggestedColumnSchema & { deletedIndex?: number }) | null = + $state(null); + + const baseColProps = { + custom: false, + draggable: false, + resizable: false + }; const NOTIFICATION_AND_MOCK_DELAY = 1250; + const COLUMN_DELETION_UNDO_TIMER_LIMIT = 10000; // 10 seconds const getColumnWidth = (columnKey: string) => Math.max(180, columnKey.length * 8 + 60); + const safeNumericValue = (value: number | undefined) => value !== undefined && isWithinSafeRange(value) ? value : undefined; @@ -85,7 +164,7 @@ const updateOverlayHeight = () => { if (!spreadsheetContainer) return; if (!headerElement || !headerElement.isConnected) { - headerElement = spreadsheetContainer.querySelector('[role="rowheader"]'); + headerElement = spreadsheetContainer?.querySelector('[role="rowheader"]'); } if (!headerElement) return; @@ -106,7 +185,7 @@ const updateOverlayBounds = () => { if (!spreadsheetContainer) return; if (!headerElement || !headerElement.isConnected) { - headerElement = spreadsheetContainer.querySelector('[role="rowheader"]'); + headerElement = spreadsheetContainer?.querySelector('[role="rowheader"]'); } if (!headerElement) return; @@ -136,18 +215,45 @@ const hasRealColumns = customColumns.some((col) => !col.isPlaceholder); if (!hasRealColumns) { - // For placeholders or no columns, position overlay to cover custom columns area - const idCell = getById('$id'); + // for placeholders or no columns, + // position overlay to cover custom columns area + let startCell = getById('$id'); + + if (staticUserColumns.length > 0) { + const lastUserColumn = staticUserColumns[staticUserColumns.length - 1]; + let lastUserCell = getById(lastUserColumn.id); + + // if not found with data-header="true", try without it + if (!lastUserCell) { + lastUserCell = headerElement!.querySelector( + `[role="cell"][data-column-id="${lastUserColumn.id}"]` + ); + } + + if (lastUserCell) { + startCell = lastUserCell; + } + } + const actionsCell = headerElement!.querySelector( '[role="cell"][data-column-id="actions"]' ); - if (idCell && actionsCell) { - const idRect = idCell.getBoundingClientRect(); + if (startCell && actionsCell) { + const startRect = startCell.getBoundingClientRect(); const actionsRect = actionsCell.getBoundingClientRect(); - const left = Math.round(idRect.right - containerRect.left); + let left = Math.round(startRect.right - containerRect.left); const actionsLeft = actionsRect.left - containerRect.left; + // ensure overlay doesn't go over select + const selectionRect = spreadsheetContainer + .querySelector('[data-select="true"]') + ?.getBoundingClientRect(); + if (selectionRect) { + const selectionRight = Math.round(selectionRect.right - containerRect.left); + left = Math.max(left, selectionRight); + } + const width = actionsLeft - left; spreadsheetContainer.style.setProperty('--group-left', `${left - 2}px`); @@ -200,15 +306,35 @@ .querySelector('[data-select="true"]') ?.getBoundingClientRect(); - // Start overlay after selection column if it exists, otherwise after $id + // determine starting point for overlay let startLeft = idRect.right; if (selectionRect && selectionRect.right > idRect.right) { startLeft = selectionRect.right; } + // if userColumns exist, + // start overlay **after** the last userColumn + if (staticUserColumns.length > 0) { + const lastUserColumn = staticUserColumns[staticUserColumns.length - 1]; + const lastUserCell = getById(lastUserColumn.id); + + if (lastUserCell) { + const lastUserRect = lastUserCell.getBoundingClientRect(); + startLeft = lastUserRect.right; + } + } + + if (selectionRect) { + startLeft = Math.max(startLeft, selectionRect.right); + } + const left = Math.round(startLeft - containerRect.left); - // get the actions column and use its left border as the boundary + // use the last visible custom column's right edge as the overlay boundary + const endRect = endCell.getBoundingClientRect(); + const endRight = Math.round(endRect.right - containerRect.left); + + // also get the actions column to ensure we don't exceed it const actionsCell = headerElement!.querySelector( '[role="cell"][data-column-id="actions"]' ); @@ -223,7 +349,9 @@ const actionsRect = actionsCell.getBoundingClientRect(); const actionsLeft = actionsRect.left - containerRect.left; - const width = actionsLeft - left; + // ensure overlay doesn't exceed bounds + const right = Math.min(endRight, actionsLeft); + const width = right - left; // Apply overlay positioning spreadsheetContainer.style.setProperty('--group-left', `${left - 2}px`); @@ -232,40 +360,126 @@ // only for mobile, we can remove if not needed! const scrollToFirstCustomColumn = () => { - if (!$isSmallViewport) return; + if (!staticUserColumns.length && !$isSmallViewport) return; if (!headerElement || !headerElement.isConnected) { - headerElement = spreadsheetContainer.querySelector('[role="rowheader"]'); + headerElement = spreadsheetContainer?.querySelector('[role="rowheader"]'); } if (!headerElement) return; - const firstCustomColumnCell = headerElement.querySelector( - `[role="cell"][data-header="true"][data-column-id="${customColumns[0]?.key}"]` - ); - const directAccessScroller = hScroller ?? findHorizontalScroller(headerElement) ?? // internal spreadsheet root main container! spreadsheetContainer.querySelector('.spreadsheet-container'); - if (firstCustomColumnCell && directAccessScroller) { - const cellRect = firstCustomColumnCell.getBoundingClientRect(); + if (!directAccessScroller) return; + + let targetCell: HTMLElement | null = null; + + if (staticUserColumns.length > 0 && !$isSmallViewport) { + const lastUserColumn = staticUserColumns[staticUserColumns.length - 1]; + targetCell = headerElement.querySelector( + `[role="cell"][data-header="true"][data-column-id="${lastUserColumn.id}"]` + ); + } else { + targetCell = headerElement.querySelector( + `[role="cell"][data-header="true"][data-column-id="${customColumns[0]?.key}"]` + ); + } + + if (targetCell) { + const cellRect = targetCell.getBoundingClientRect(); const scrollerRect = directAccessScroller.getBoundingClientRect(); const scrollLeft = directAccessScroller.scrollLeft + cellRect.left - scrollerRect.left - 40; directAccessScroller.scrollTo({ left: Math.max(0, scrollLeft), - behavior: 'smooth' + behavior: 'instant' }); } }; + function updateColumnHighlight() { + const activeColumnId = selectedColumnId || hoveredColumnId; + if (!spreadsheetContainer || !activeColumnId) return; + + const headerCell = spreadsheetContainer.querySelector( + `[role="rowheader"] [role="cell"][data-column-id="${activeColumnId}"]` + ); + + if (!headerCell) return; + + // calculate position similar to columns-range-overlay logic + if (!headerElement || !headerElement.isConnected) { + headerElement = spreadsheetContainer.querySelector('[role="rowheader"]'); + } + + if (!headerElement) return; + + const containerRect = spreadsheetContainer.getBoundingClientRect(); + const cellRect = headerCell.getBoundingClientRect(); + + const left = Math.round(cellRect.left - containerRect.left); + const width = cellRect.width; + + const isHovered = !selectedColumnId && hoveredColumnId; + const isFirstColumn = activeColumnId === customColumns[0]?.key; + const isLastColumn = activeColumnId === customColumns[customColumns.length - 1]?.key; + + let leftAdjustment = -2; + let widthAdjustment = 2; + if (isHovered && (isFirstColumn || isLastColumn)) { + leftAdjustment = 0; + } + + // get actions boundary to prevent hover overlay over it + const actionsCell = headerElement.querySelector( + '[role="cell"][data-column-id="actions"]' + ); + + let finalWidth = width + widthAdjustment; + + if (isHovered && actionsCell) { + const actionsRect = actionsCell.getBoundingClientRect(); + const actionsLeft = actionsRect.left - containerRect.left; + const overlayRight = left + leftAdjustment + finalWidth; + + const borderWidth = 2; + if (overlayRight + borderWidth > actionsLeft) { + finalWidth = actionsLeft - (left + leftAdjustment) - borderWidth; + } + } + + spreadsheetContainer.style.setProperty('--highlight-left', `${left + leftAdjustment}px`); + spreadsheetContainer.style.setProperty('--highlight-width', `${finalWidth}px`); + + if (isHovered) { + const tooltipElement = + spreadsheetContainer.querySelector('.custom-tooltip'); + const tooltipWidth = tooltipElement ? tooltipElement.offsetWidth : 200; + const defaultOffset = 325; + const smallerOffset = 225; + const viewportWidth = window.innerWidth; + + // check how much space is available to the right of the column + const columnRightEdge = left + leftAdjustment + finalWidth; + const availableSpace = viewportWidth - columnRightEdge; + + // use smaller offset if there isn't enough space for default offset + tooltip + const shouldUseSmallerOffset = availableSpace < defaultOffset + tooltipWidth; + const tooltipOffset = shouldUseSmallerOffset ? smallerOffset : defaultOffset; + + spreadsheetContainer.style.setProperty('--tooltip-offset', `${tooltipOffset}px`); + } + } + const recalcAll = () => { updateOverlayHeight(); updateOverlayBounds(); + updateColumnHighlight(); }; /** @@ -276,6 +490,16 @@ scrollAnimationFrame = requestAnimationFrame(() => { recalcAll(); + + // check if selected column is still visible after scroll + if (selectedColumnId && !isColumnVisible(selectedColumnId)) { + resetSelectedColumn(); + } + + if (hoveredColumnId && !isColumnVisible(hoveredColumnId)) { + hoveredColumnId = null; + } + scrollAnimationFrame = null; }); }; @@ -297,38 +521,41 @@ width: { min: getColumnWidth(col.key) }, icon: columnOption?.icon, draggable: false, - resizable: false + resizable: false, + custom: true }; }); }); - const getRowColumns = (): Column[] => { - const minColumnWidth = 180; + const getRowColumns = (): (Column & { custom: boolean })[] => { + const minColumnWidth = 250; const fixedWidths = { id: minColumnWidth, actions: 40, selection: 40 }; - // calculate base widths and total - const columnsWithBase = customSuggestedColumns.map((col) => ({ - ...col, - baseWidth: Math.max(minColumnWidth, getColumnWidth(col.id)) - })); + const equalWidthColumns = [...staticUserColumns, ...customSuggestedColumns]; - const totalUsed = + const totalBaseWidth = fixedWidths.id + fixedWidths.actions + fixedWidths.selection + - columnsWithBase.reduce((sum, col) => sum + col.baseWidth, 0); + equalWidthColumns.length * minColumnWidth; - // distribute excess space equally across custom columns const viewportWidth = spreadsheetContainer?.clientWidth || - (typeof window !== 'undefined' ? window.innerWidth : totalUsed); + (typeof window !== 'undefined' ? window.innerWidth : totalBaseWidth); + const excessSpace = Math.max(0, viewportWidth - totalBaseWidth); const extraPerColumn = - Math.max(0, viewportWidth - totalUsed) / (columnsWithBase.length || 1); + equalWidthColumns.length > 0 ? excessSpace / equalWidthColumns.length : 0; + const distributedWidth = minColumnWidth + extraPerColumn; - const finalCustomColumns = columnsWithBase.map((col) => ({ + const userColumnsWithWidth = staticUserColumns.map((col) => ({ ...col, - width: { min: col.baseWidth + extraPerColumn } + width: distributedWidth + })); + + const finalCustomColumns = customSuggestedColumns.map((col) => ({ + ...col, + width: { min: distributedWidth } })); return [ @@ -340,6 +567,7 @@ icon: IconFingerPrint, ...baseColProps }, + ...userColumnsWithWidth, ...finalCustomColumns, { id: 'actions', @@ -353,9 +581,14 @@ }; const spreadsheetColumns = $derived(getRowColumns()); - const emptyCells = $derived(($isSmallViewport ? 14 : 17) + (!$expandTabs ? 2 : 0)); + const emptyCells = $derived( + ($isSmallViewport ? 14 : 17) + (!$expandTabs ? 2 : 0) - userDataRows.length + ); onMount(async () => { + columnsOrder.set(preferences.getColumnOrder(tableId)); + columnsWidth.set(preferences.getColumnWidths(tableId)); + if (spreadsheetContainer) { resizeObserver = new ResizeObserver(recalcAll); resizeObserver.observe(spreadsheetContainer); @@ -374,20 +607,22 @@ // these are referenced in // `table-[table]/+page.svelte` $tableColumnSuggestions.table = null; + $tableColumnSuggestions.force = false; $tableColumnSuggestions.enabled = false; } $tableColumnSuggestions.context = null; $tableColumnSuggestions.thinking = false; + + // reset selection! + resetSelectedColumn(); } async function suggestColumns() { $tableColumnSuggestions.thinking = true; - if ($isSmallViewport) { - await tick(); - scrollToFirstCustomColumn(); - } + await tick(); + scrollToFirstCustomColumn(); let suggestedColumns: { total: number; @@ -400,7 +635,7 @@ try { if (VARS.MOCK_AI_SUGGESTIONS) { /* animation */ - await sleep(NOTIFICATION_AND_MOCK_DELAY); + await sleep(NOTIFICATION_AND_MOCK_DELAY * 100); suggestedColumns = mockSuggestions; } else { suggestedColumns = (await sdk @@ -473,17 +708,22 @@ } } - function onPopoverShowStateChanged(value: boolean) { - showFloatingBar = !value; + async function updateOverlaysForMobile(value: boolean) { if ($isSmallViewport) { setTimeout(() => { - [rangeOverlayEl, fadeBottomOverlayEl].forEach((el) => { + [rangeOverlayEl, fadeBottomOverlayEl, snowFadeBottomOverlayEl].forEach((el) => { if (el) { el.style.opacity = value ? '0' : '1'; } }); }, 0); } + } + + function onPopoverShowStateChanged(value: boolean) { + showFloatingBar = !value; + showHeadTooltip = !value; + updateOverlaysForMobile(value); const currentScrollLeft = hScroller?.scrollLeft || 0; @@ -492,6 +732,9 @@ hScroller.scrollLeft = currentScrollLeft; } }); + + // reset selection! + resetSelectedColumn(); } function updateColumn(columnId: string, updates: Partial) { @@ -515,6 +758,174 @@ return !['$id', '$createdAt', '$updatedAt', 'actions'].includes(id); } + function resetSelectedColumn() { + selectedColumnId = null; + previousColumnId = null; + /*selectedColumnName = null;*/ + } + + // small decor, hides previous cell's right border visibility! + function handlePreviousColumnsBorder(columnId: string, hide: boolean = true) { + const allHeaders = Array.from( + spreadsheetContainer.querySelectorAll( + '[role="rowheader"] [role="cell"][data-column-id]' + ) + ); + + const selectedIndex = allHeaders.findIndex( + (cell) => cell.getAttribute('data-column-id') === columnId + ); + + if (selectedIndex > 0) { + const prevColumnId = allHeaders[selectedIndex - 1].getAttribute('data-column-id'); + if (prevColumnId) { + const previousCells = spreadsheetContainer.querySelectorAll( + `[role="rowheader"] [role="cell"][data-column-id="${prevColumnId}"]` + ); + + previousCells.forEach((cell) => { + if (hide) { + cell.classList.add('hide-border'); + } else { + cell.classList.remove('hide-border'); + } + }); + } + } + } + + function isColumnVisible(columnId: string) { + if (!spreadsheetContainer || !hScroller) return true; + + const columnCell = spreadsheetContainer.querySelector( + `[role="rowheader"] [role="cell"][data-column-id="${columnId}"]` + ); + + if (!columnCell) return false; + + const cellRect = columnCell.getBoundingClientRect(); + const scrollerRect = hScroller.getBoundingClientRect(); + + // stickies have 40px width + const STICKY_COLUMN_WIDTH = 40; + + // calculate available viewport bounds (excluding both 40px sticky columns) + const leftBound = scrollerRect.left + STICKY_COLUMN_WIDTH; // Selection column (40px) + const rightBound = scrollerRect.right - STICKY_COLUMN_WIDTH; // Actions column (40px) + + const safetyMargin = 2; + return ( + cellRect.left >= leftBound - safetyMargin && cellRect.right <= rightBound + safetyMargin + ); + } + + function scrollColumnIntoView(columnId: string) { + if (!spreadsheetContainer || !hScroller) return false; + + const columnCell = spreadsheetContainer.querySelector( + `[role="rowheader"] [role="cell"][data-column-id="${columnId}"]` + ); + + if (!columnCell) return false; + + const cellRect = columnCell.getBoundingClientRect(); + const scrollerRect = hScroller.getBoundingClientRect(); + + // calculate scroll needed to center the column in view + const scrollLeft = + hScroller.scrollLeft + + cellRect.left - + scrollerRect.left - + (scrollerRect.width - cellRect.width) / 2; + + hScroller.scrollTo({ + left: Math.max(0, scrollLeft), + behavior: 'smooth' + }); + + return true; + } + + function deleteColumn(columnId: string) { + if (!columnId) return; + + let columnIndex = -1; + let columnSchema: SuggestedColumnSchema = null; + + for (let index = 0; index < customColumns.length; index++) { + if (customColumns[index].key === columnId) { + columnIndex = index; + columnSchema = customColumns[index]; + break; + } + } + + if (columnIndex === -1 || !columnSchema) { + return; + } + + // remove the column + customColumns.splice(columnIndex, 1); + + // store column with its index for undo + columnBeingDeleted = { ...columnSchema, deletedIndex: columnIndex }; + + // clear any existing timer + if (undoTimer) { + clearTimeout(undoTimer); + } + + // start 10-second undo timer + undoTimer = setTimeout(() => { + undoTimer = null; + selectedColumnId = null; + columnBeingDeleted = null; + selectedColumnName = null; + }, COLUMN_DELETION_UNDO_TIMER_LIMIT); + + // reset selection! + resetSelectedColumn(); + + // see overlay is visible after deletion on mobile! + setTimeout(() => updateOverlaysForMobile(false), 150); + + // recalculate view after deletion + requestAnimationFrame(() => recalcAll()); + } + + function undoDelete() { + if (!columnBeingDeleted) return; + + const { deletedIndex, ...columnData } = columnBeingDeleted; + + // restore column at its original index + if (deletedIndex !== undefined && deletedIndex >= 0) { + customColumns.splice(deletedIndex, 0, columnData); + } else { + // fallback: add at the end if index is missing + customColumns.push(columnData); + } + + // clear undo state + columnBeingDeleted = null; + + // clear timer + if (undoTimer) { + clearTimeout(undoTimer); + undoTimer = null; + } + + // recalculate view after restore + requestAnimationFrame(() => { + recalcAll(); + + tick().then(() => { + selectedColumnId = columnData.key; + selectedColumnName = columnData.key; + }); + }); + } + function showIndexSuggestionsNotification() { // safeguard anyways! if (!isCloud) return; @@ -542,8 +953,20 @@ async function createColumns() { creatingColumns = true; + selectedColumnId = null; + const client = sdk.forProject(page.params.region, page.params.project); + const isAnyEmpty = customColumns.some((col) => !col.key); + if (isAnyEmpty) { + creatingColumns = false; + addNotification({ + type: 'warning', + message: 'Some columns have invalid keys' + }); + return; + } + try { const results = []; @@ -552,7 +975,8 @@ databaseId: page.params.database, tableId: page.params.table, key: column.key, - required: column.required || false + required: column.required || false, + encrypt: 'encrypt' in column ? column.encrypt : undefined }; let columnResult: Columns; @@ -650,6 +1074,8 @@ timeout: NOTIFICATION_AND_MOCK_DELAY }); + resetSuggestionsStore(true); + // show index notification! showIndexSuggestionsNotification(); @@ -664,13 +1090,12 @@ } } - function createPlaceholderColumn( - index: number - ): SuggestedColumnSchema & { elements?: []; isPlaceholder?: boolean } { + function createPlaceholderColumn(index: number): SuggestedColumnSchema { return { key: `column${index + 1}`, type: 'string', required: false, + array: false, default: null, format: null, size: undefined, @@ -681,6 +1106,159 @@ }; } + // scroll to view if needed and select! + function selectColumnWithId(column: Column) { + if (creatingColumns) return; + + const columnId = column.id; + selectedColumnName = column.title; + if (!isColumnVisible(columnId)) { + scrollColumnIntoView(columnId); + setTimeout(() => (selectedColumnId = columnId), 300); + } else { + selectedColumnId = columnId; + } + + columnBeingDeleted = null; + } + + /*function fadeSlide(_: Node, { y = 8, duration = 200 } = {}) { + return { + duration, + css: (time: number) => ` + opacity: ${time}; + transform: translateY(${(1 - time) * y}px); + ` + }; + }*/ + + function columnHoverMouseTracker(event: MouseEvent) { + if (hoveredColumnId && event.target instanceof Element) { + const hoveredButton = event.target.closest('[data-column-hover]'); + const currentColumnId = hoveredButton?.getAttribute('data-column-hover'); + + if (currentColumnId !== hoveredColumnId) { + hoveredColumnId = null; + } + } + } + + $effect(() => { + if (!spreadsheetContainer) return; + + // remove existing hide-border classes + const hiddenCells = spreadsheetContainer.querySelectorAll('[role="cell"].hide-border'); + hiddenCells.forEach((cell) => cell.classList.remove('hide-border')); + + if (!selectedColumnId) return; + + setTimeout(() => { + // hide borders for selected column and previous column + const selectedCells = spreadsheetContainer.querySelectorAll( + `[role="cell"][data-column-id="${selectedColumnId}"]` + ); + + selectedCells.forEach((cell) => cell.classList.add('hide-border')); + + // find and hide previous column's borders (which create the left edge of selected column) + const allHeaders = Array.from( + spreadsheetContainer.querySelectorAll( + '[role="rowheader"] [role="cell"][data-column-id]' + ) + ); + const selectedIndex = allHeaders.findIndex( + (cell) => cell.getAttribute('data-column-id') === selectedColumnId + ); + + if (selectedIndex > 0) { + const prevColumnId = allHeaders[selectedIndex - 1].getAttribute('data-column-id'); + if (prevColumnId) { + const previousCells = spreadsheetContainer.querySelectorAll( + `[role="cell"][data-column-id="${prevColumnId}"]` + ); + previousCells.forEach((cell) => cell.classList.add('hide-border')); + } + } + }, 300); + + // update position + updateColumnHighlight(); + + // track for next selection - + // but only if we had a `real` previous selection + if (previousColumnId !== null) { + previousColumnId = selectedColumnId; + } else { + // fresh after a deselect + // set it for future switches + setTimeout(() => (previousColumnId = selectedColumnId), 25); + } + }); + + // mark suggested column cells so CSS can target them specifically + $effect(() => { + if (!spreadsheetContainer) return; + + // get all custom column IDs + const suggestedColumnIds = customColumns.map((col) => col.key); + const firstSuggestedColumnId = suggestedColumnIds[0]; + + const columnBeforeOverlay = + staticUserColumns.length > 0 + ? staticUserColumns[staticUserColumns.length - 1].id + : '$id'; + + const allCells = spreadsheetContainer.querySelectorAll('[role="cell"][data-column-id]'); + allCells.forEach((cell) => { + const columnId = cell.getAttribute('data-column-id'); + if (columnId && suggestedColumnIds.includes(columnId)) { + cell.setAttribute('data-suggested-column', 'true'); + if (columnId === firstSuggestedColumnId) { + cell.setAttribute('data-first-suggested-column', 'true'); + } else { + cell.removeAttribute('data-first-suggested-column'); + } + } else { + cell.removeAttribute('data-suggested-column'); + cell.removeAttribute('data-first-suggested-column'); + } + + if (columnId === columnBeforeOverlay) { + cell.setAttribute('data-column-before-overlay', 'true'); + } else { + cell.removeAttribute('data-column-before-overlay'); + } + }); + }); + + $effect(() => { + if (!spreadsheetContainer) return; + + const allCells = spreadsheetContainer.querySelectorAll('[role="cell"]'); + allCells.forEach((cell) => { + const resizer = cell.querySelector('.column-resizer-disabled') as HTMLDivElement; + if (resizer) resizer.style.display = ''; + }); + + if (!hoveredColumnId) return; + + // auto-scroll if hovered column is out of bounds + /*if (!isColumnVisible(hoveredColumnId)) { + scrollColumnIntoView(hoveredColumnId); + }*/ + + const hoveredCells = spreadsheetContainer.querySelectorAll( + `[role="cell"][data-column-id="${hoveredColumnId}"]` + ); + + hoveredCells.forEach((cell) => { + const resizer = cell.querySelector('.column-resizer-disabled') as HTMLDivElement; + if (resizer) resizer.style.display = 'none'; + }); + + updateColumnHighlight(); + }); + onDestroy(() => { resizeObserver?.disconnect(); hScroller?.removeEventListener('scroll', recalcAllThrottled); @@ -696,12 +1274,14 @@
0} class:thinking={$tableColumnSuggestions.thinking} class="databases-spreadsheet spreadsheet-container-outer" style:--overlay-icon-color="#fd366e99" - style:--non-overlay-icon-color="--fgcolor-neutral-weak"> + style:--non-overlay-icon-color="--fgcolor-neutral-weak" + onmousemove={columnHoverMouseTracker}>
+ + + {#if selectedColumnId || hoveredColumnId} + {@const activeColumnId = selectedColumnId || hoveredColumnId} + {@const isHovered = !selectedColumnId && hoveredColumnId} + {@const isFirstColumn = activeColumnId === customColumns[0]?.key} + {@const isLastColumn = activeColumnId === customColumns[customColumns.length - 1]?.key} +
+
+ + + {/if}
{}}> + bottomActionClick={() => {}} + let:root> {#each spreadsheetColumns as column, index (index)} {#if column.isAction} - + @@ -736,178 +1345,224 @@ ? '--non-overlay-icon-color' : '--overlay-icon-color'} {@const isColumnInteractable = - isCustomColumn(column.id) && !columnObj.isPlaceholder} - - - {#snippet children(toggle)} - { - // tablet viewport check because context-menu - // can be triggered on long hold clicks as well! - if (isColumnInteractable && !$isTabletViewport) { - toggle(event); - } - }}> - - - {column.title} - - - -
+ + + {column.title} + + + + + + {:else} + { + if (triggerColumnId === column.id) { + triggerColumnId = null; + return true; + } + + return false; + }}> + {#snippet children(toggle)} + { + // tablet viewport check because context-menu + // can be triggered on long hold clicks as well! + if (isColumnInteractable && !$isTabletViewport) { + toggle(event); + } + }}> + + - { - if ( - isColumnInteractable && - !$isTabletViewport - ) { - toggle(event); - } + style:--animation-delay={`${isColumnInteractable ? (index - 1) * 100 : 0}ms`} + title={column.title}> + {column.title} + + + {@render changeColumnTypePopover({ + id: column.id, + columnObj, + iconColor: columnIconColor, + icon: column.icon, + isColumnInteractable, + index + })} + + + + {#if !$isTabletViewport} +
{ + isInlineEditing = true; + showHeadTooltip = false; + resetSelectedColumn(); + handlePreviousColumnsBorder(column.id); + }} + onfocusout={() => { + showHeadTooltip = true; + isInlineEditing = false; + handlePreviousColumnsBorder( + column.id, + false + ); }}> - {#if !columnObj?.isPlaceholder} - - {/if} - -
- -
- - - {#each basicColumnOptions as option} - { - toggle(); - updateColumn(column.id, { - type: option.type, - format: - option.format || null - }); - }}> - - - {option.name} - - - {/each} - - -
- - - - - {#if !$isTabletViewport} -
+ + + {#if columnIcon} + {@render changeColumnTypePopover({ + id: column.id, + columnObj, + iconColor: columnIconColor, + icon: column.icon, + isColumnInteractable, + index + })} + {/if} + + +
+ {/if} +
+
+ {/snippet} + + {#snippet tooltipChildren()} + {#if columnObj} + {@const selectedOption = getColumnOption( + columnObj.type, + columnObj.format + )} + {@const ColumnComponent = selectedOption?.component} + + - - {#if columnIcon} - - {/if} - - -
- {/if} -
- - {/snippet} - - {#snippet tooltipChildren()} - {#if columnObj} - {@const selectedOption = getColumnOption( - columnObj.type, - columnObj.format - )} - {@const ColumnComponent = selectedOption?.component} - - - - - { - const newOption = columnOptions.find( - (opt) => opt.name === e.detail - ); - if (newOption) { - updateColumn(column.id, { - type: newOption.type, - format: newOption.format || null - }); - } - }} - options={basicColumnOptions.map((col) => { - return { - label: col.name, - value: col.name, - leadingIcon: col.icon - }; - })} /> - + pattern="^[A-Za-z0-9][A-Za-z0-9._\-]*$" /> - {#if ColumnComponent} - - {/if} - - {/if} - {/snippet} - + { + const newOption = columnOptions.find( + (opt) => opt.name === e.detail + ); + if (newOption) { + updateColumn(column.id, { + type: newOption.type, + format: newOption.format || null + }); + } + }} + options={basicColumnOptions.map((col) => { + return { + label: col.name, + value: col.name, + leadingIcon: col.icon + }; + })} /> + + + {#if ColumnComponent} + + {/if} + + {/if} + {/snippet} + + {#snippet mobileFooterChildren(toggle)} + { + toggle(event); + deleteColumn(column.id); + }} + style="position: absolute; left: 1rem;" + >Delete + + {/snippet} + + {/if} {/if} {/each} + + {#each userDataRows as row} + + {#each spreadsheetColumns as column} + {@const columnObj = getColumn(column.id)} + {@const interactable = + isCustomColumn(column.id) && columnObj && !columnObj.isPlaceholder} + + {@render rowCellInteractiveButton({ + interactable, + column, + row + })} + + {/each} + + {/each} + + {#each Array.from({ length: emptyCells }) as _} + + {#each spreadsheetColumns as column} + {@const columnObj = getColumn(column.id)} + {@const interactable = + isCustomColumn(column.id) && columnObj && !columnObj.isPlaceholder} + + {@render rowCellInteractiveButton({ + interactable, + column + })} + + {/each} + + {/each}
@@ -917,6 +1572,12 @@ data-collapsed-tabs={!$expandTabs}>
+
+
+ {#if $tableColumnSuggestions.thinking}
@@ -942,13 +1603,85 @@
{:else if customColumns.some((col) => !col.isPlaceholder) && showFloatingBar} + + {@const isUndoDeleteMode = columnBeingDeleted && columnBeingDeleted?.key !== null} + {@const columnName = isUndoDeleteMode ? columnBeingDeleted?.key : selectedColumnName} + {@const hasSelection = selectedColumnId !== null || isUndoDeleteMode} + + {#if !creatingColumns} +
+ + + + + + + {#if isUndoDeleteMode} + was deleted. You can undo this action. + {:else} + is selected + {/if} + + + + + + + {#if !isUndoDeleteMode} + (selectedColumnId = null)}> + Cancel + + + + + {/if} + !col.isPlaceholder).length <= 1} + on:click={() => { + if (isUndoDeleteMode) { + undoDelete(); + } else { + deleteColumn(selectedColumnId); + } + }}> + {#if isUndoDeleteMode} + Undo + {:else} + Delete + {/if} + + + + +
+ {/if} + +
+ class:creating-columns={creatingColumns} + class:has-selection={hasSelection}> - + {#if creatingColumns} {/if} @@ -958,49 +1691,203 @@ color="--fgcolor-neutral-secondary" style="white-space: nowrap"> {creatingColumns - ? 'Creating columns' + ? 'Creating columns...' : $isSmallViewport - ? 'Review and edit suggested columns' - : 'Review and edit suggested columns before applying'} + ? 'Click headers or cells to edit columns' + : 'Click headers or cells to edit columns before applying'} - - { - customColumns = []; - resetSuggestionsStore(); - }} - style="opacity: {creatingColumns ? '0' : '1'}" - >Dismiss - - Apply - - + {#if !creatingColumns} + + { + customColumns = []; + resetSuggestionsStore(); + }} + style="opacity: {creatingColumns ? '0' : '1'}" + >Dismiss + + Apply + + + {/if}
{/if} + + +{#snippet rowCellInteractiveButton({ interactable, column, row = null })} + +{/snippet} + +{#snippet changeColumnTypePopover({ id, columnObj, iconColor, icon, isColumnInteractable, index })} + +
+ { + if (isColumnInteractable && !$isTabletViewport) { + toggle(event); + resetSelectedColumn(); + } + }}> + {#if !columnObj?.isPlaceholder} + + {/if} + +
+ +
+ + + {#each basicColumnOptions as option} + { + toggle(); + updateColumn(id, { + type: option.type, + format: option.format || null + }); + }}> + + + {option.name} + + + {/each} + + +
+
+{/snippet} + +{#snippet edgeGradients(side: 'left' | 'right')} + + {@const gradientConfigs = [ + { pos: '20%', color: 'var(--border-pink)', spread: '25%', delay: '0s' }, + { pos: '50%', color: 'var(--border-orange)', spread: '15%', delay: '1s' }, + { pos: '80%', color: 'var(--border-pink)', spread: '25%', delay: '2s' }, + { pos: '35%', color: 'var(--border-pink)', spread: '40%', delay: '0.5s' }, + { pos: '65%', color: 'var(--border-orange)', spread: '40%', delay: '1.5s' } + ]} + {@const xPosition = side === 'left' ? '0%' : '100%'} + +
+ {#each gradientConfigs as grad} +
+
+ {/each} +
+{/snippet} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/icon/ai.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/icon/ai.svelte index 1d51db1688..9fae0e544b 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/icon/ai.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/icon/ai.svelte @@ -71,8 +71,16 @@ border: 1.25px solid rgba(253, 54, 110, 0.12); padding: 5px 0; + min-width: 40px; width: 40px !important; height: 40px !important; + + & svg { + width: 30px; + height: 30px; + flex-shrink: 0; + aspect-ratio: 1/1; + } } :global(.ai-icon-holder.notification) { diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/icon/aiForButton.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/icon/aiForButton.svelte new file mode 100644 index 0000000000..df6de05161 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/icon/aiForButton.svelte @@ -0,0 +1,22 @@ + + + + + + + + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/indexes.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/indexes.svelte index 2f33a1af66..85158b884d 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/indexes.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/indexes.svelte @@ -8,7 +8,7 @@ mockSuggestions, type SuggestedIndexSchema } from './store'; - import { Modal, Confirm } from '$lib/components'; + import { Modal } from '$lib/components'; import SideSheet from '../table-[table]/layout/sidesheet.svelte'; import { isSmallViewport } from '$lib/stores/viewport'; import { IndexType, type Models } from '@appwrite.io/console'; @@ -32,7 +32,6 @@ let creatingIndexes = $state(false); let loadingSuggestions = $state(false); let indexes = $state([]); - let confirmDismiss = $state(false); let columnOptions: Array<{ value: string; label: string; @@ -195,7 +194,6 @@ function dismissIndexes() { indexes = []; - confirmDismiss = false; $showIndexesSuggestions = false; } @@ -354,13 +352,7 @@ text size="s" disabled={loadingSuggestions || creatingIndexes} - on:click={() => { - if (indexes.length > 0 && !creatingIndexes) { - confirmDismiss = true; - } else { - $showIndexesSuggestions = false; - } - }}>Cancel + on:click={() => dismissIndexes()}>Cancel {:else} - +
+ + + + + {headerTooltipText} + + +
{/if}
@@ -56,6 +80,10 @@ showSheet = false; } }}> + {#snippet footer()} + {@render mobileFooterChildren?.(() => (showSheet = false))} + {/snippet} + {@render tooltipChildren(() => (showSheet = false))} {/if} diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/store.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/store.ts index f1792e3911..834bfc7aa2 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/store.ts +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/store.ts @@ -3,6 +3,7 @@ import { IndexType } from '@appwrite.io/console'; import { columnOptions } from '../table-[table]/columns/store'; export type TableColumnSuggestions = { + force: boolean; enabled: boolean; thinking: boolean; context?: string | undefined; @@ -18,11 +19,15 @@ export type SuggestedColumnSchema = { key: string; type: string; required: boolean; + array?: boolean; default?: string | number | boolean | number[] | number[][] | number[][][] | null; size?: number; min?: number; max?: number; format?: string | null; + encrypt?: boolean | null; + elements?: string[]; + isPlaceholder?: boolean; }; export enum IndexOrder { @@ -43,11 +48,14 @@ export const tableColumnSuggestions = writable({ enabled: false, context: null, thinking: false, - table: null + table: null, + force: false }); export const showIndexesSuggestions = writable(false); +export const showColumnsSuggestionsModal = writable(false); + export const mockSuggestions: { total: number; columns: ColumnInput[] } = { total: 7, columns: [ @@ -68,7 +76,7 @@ export const mockSuggestions: { total: number; columns: ColumnInput[] } = { formatOptions: null }, { - name: 'publishedYear', + name: 'year', type: 'integer', size: null, format: null, @@ -79,7 +87,7 @@ export const mockSuggestions: { total: number; columns: ColumnInput[] } = { } }, { - name: 'genre', + name: 'category', type: 'string', size: 64, format: null, @@ -88,7 +96,7 @@ export const mockSuggestions: { total: number; columns: ColumnInput[] } = { default: null }, { - name: 'isbn', + name: 'code', type: 'string', size: 13, required: false, @@ -96,7 +104,7 @@ export const mockSuggestions: { total: number; columns: ColumnInput[] } = { default: null }, { - name: 'language', + name: 'spokenLanguage', type: 'string', size: 32, format: null, @@ -105,7 +113,7 @@ export const mockSuggestions: { total: number; columns: ColumnInput[] } = { default: null }, { - name: 'pageCount', + name: 'count', type: 'integer', required: false, min: 1, @@ -123,6 +131,7 @@ export type ColumnInput = { min?: number; max?: number; format?: string; + elements?: string[]; formatOptions?: { min?: number; max?: number; @@ -134,6 +143,7 @@ export function mapSuggestedColumns(columns: T[]): Sugges key: col.name, type: col.type, required: col.required ?? false, + array: false, default: col.default ?? null, size: col.type === 'string' ? (col.size ?? undefined) : undefined, min: @@ -144,7 +154,8 @@ export function mapSuggestedColumns(columns: T[]): Sugges col.type === 'integer' || col.type === 'double' ? (col.max ?? col.formatOptions?.max ?? undefined) : undefined, - format: col.format ?? null + format: col.format ?? null, + elements: col.elements ?? undefined })); } diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/+layout.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/+layout.svelte index 07f9bd7819..bc4f7f186d 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/+layout.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/+layout.svelte @@ -74,6 +74,8 @@ import { isTabletViewport } from '$lib/stores/viewport'; import IndexesSuggestions from '../(suggestions)/indexes.svelte'; + import ColumnsSuggestions from '../(suggestions)/columns.svelte'; + import { showColumnsSuggestionsModal } from '../(suggestions)'; let editRow: EditRow; let editRelatedRow: EditRelatedRow; @@ -608,6 +610,8 @@ + + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/indexes/+page.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/indexes/+page.svelte index 134a18b305..9f2313ae13 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/indexes/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/indexes/+page.svelte @@ -23,6 +23,7 @@ Typography } from '@appwrite.io/pink-svelte'; import { + IconBookOpen, IconDotsHorizontal, IconEye, IconPlus, @@ -37,6 +38,10 @@ import { showCreateColumnSheet } from '../store'; import { isSmallViewport } from '$lib/stores/viewport'; import { page } from '$app/state'; + import { showIndexesSuggestions, showColumnsSuggestionsModal } from '../../(suggestions)'; + import IconAI from '../../(suggestions)/icon/aiForButton.svelte'; + import EmptySheetCards from '../layout/emptySheetCards.svelte'; + import { isCloud } from '$lib/system'; import { realtime } from '$lib/stores/sdk'; import { invalidate } from '$app/navigation'; import { Dependencies } from '$lib/constants'; @@ -66,14 +71,14 @@ const spreadsheetColumns = $derived([ { id: 'key', - width: getColumnWidth('key', $isSmallViewport ? 250 : 200), - minimumWidth: $isSmallViewport ? 250 : 200, + width: getColumnWidth('key', 250), + minimumWidth: 250, resizable: true }, { id: 'type', - width: getColumnWidth('type', 120), - minimumWidth: 120, + width: getColumnWidth('type', 200), + minimumWidth: 200, resizable: true }, { @@ -296,27 +301,100 @@ {:else} - (showCreateIndex = true), - disabled: !$table?.columns?.length - } - }} /> + + {#snippet subtitle()} + {#if isCloud} + + Need a hand? Learn more in the + + docs. + + + {/if} + {/snippet} + + {#snippet actions()} + {#if isCloud} + { + showIndexesSuggestions.update(() => true); + }} /> + {/if} + + { + showCreateIndex = true; + }} /> + + {#if !isCloud} + + {/if} + {/snippet} + {/if} {:else} - { - $showCreateColumnSheet.show = true; - } - } - }} /> + + {#snippet subtitle()} + {#if isCloud} + + Need a hand? Learn more in the + + docs. + + + {/if} + {/snippet} + + {#snippet actions()} + {#if isCloud} + { + $showColumnsSuggestionsModal = true; + }} /> + + { + $showCreateColumnSheet.show = true; + }} /> + {:else} + { + $showCreateColumnSheet.show = true; + }} /> + + + {/if} + {/snippet} + {/if} {#if selectedIndexes.length > 0} diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/layout/emptySheet.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/layout/emptySheet.svelte index 8141cf0a7a..e3d832a8a2 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/layout/emptySheet.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/layout/emptySheet.svelte @@ -19,43 +19,57 @@ expandTabs } from '../store'; import SpreadsheetContainer from './spreadsheet.svelte'; - import { onDestroy, onMount } from 'svelte'; + import { onDestroy, onMount, type Snippet } from 'svelte'; import { debounce } from '$lib/helpers/debounce'; import { columnOptions } from '../columns/store'; type Mode = 'rows' | 'rows-filtered' | 'indexes'; - interface Action { - text?: string; - disabled?: boolean; - onClick?: () => void; - } - const { mode, - showActions = true, customColumns = [], title, - actions + subtitle, + actions, + showActions } = $props<{ mode: Mode; - showActions?: boolean; customColumns?: Column[]; title?: string; - actions?: { - primary?: Action; - random?: Action; - }; + subtitle?: Snippet; + actions?: Snippet; + showActions?: boolean; }>(); let spreadsheetContainer: HTMLElement; let headerElement: HTMLElement | null = null; let resizeObserver: ResizeObserver; + let overlayOffsetHandler: ResizeObserver; + + let overlayLeftOffset = $state('0px'); + let overlayTopOffset = $state('auto'); let dynamicOverlayHeight = $state('60.5vh'); const baseColProps = { draggable: false, resizable: false }; + const updateOverlayLeftOffset = () => { + if (spreadsheetContainer) { + const containerRect = spreadsheetContainer.getBoundingClientRect(); + overlayLeftOffset = `${containerRect.left}px`; + } + + // calculate vertical top position + if (!headerElement || !headerElement.isConnected) { + headerElement = spreadsheetContainer?.querySelector('[role="rowheader"]'); + } + + if (headerElement) { + const headerRect = headerElement.getBoundingClientRect(); + overlayTopOffset = `${headerRect.bottom}px`; + } + }; + const updateOverlayHeight = () => { if (!spreadsheetContainer) return; @@ -82,6 +96,9 @@ if (spreadsheetContainer) { resizeObserver = new ResizeObserver(debouncedUpdateOverlayHeight); resizeObserver.observe(spreadsheetContainer); + + overlayOffsetHandler = new ResizeObserver(updateOverlayLeftOffset); + overlayOffsetHandler.observe(spreadsheetContainer); } }); @@ -89,74 +106,141 @@ if (resizeObserver) { resizeObserver.disconnect(); } + + if (overlayOffsetHandler) { + overlayOffsetHandler.disconnect(); + } }); const getCustomColumns = (): Column[] => customColumns.map((col: Column) => ({ ...col, - width: 180, hide: false, icon: columnOptions.find((colOpt) => colOpt.type === col?.type)?.icon, ...baseColProps })); - const getRowColumns = (): Column[] => [ - { - id: '$id', - title: '$id', - type: 'string', - width: 180, - icon: IconFingerPrint, - ...baseColProps - }, - ...getCustomColumns(), - { - id: '$createdAt', - title: '$createdAt', - type: 'datetime', - width: 180, - icon: IconCalendar, - ...baseColProps - }, - { - id: '$updatedAt', - title: '$updatedAt', - type: 'datetime', - width: 180, - icon: IconCalendar, - ...baseColProps - }, - { - id: 'actions', - title: '', - type: 'string', - icon: IconPlus, - width: customColumns.length ? 555 : 832, - ...baseColProps - }, - { - id: 'empty', - title: '', - type: 'string', - ...baseColProps + const getRowColumns = (): Column[] => { + const minColumnWidth = 180; + const fixedWidths = { id: 180, actions: 40 }; + const hasCustomColumns = customColumns.length > 0; + + const customColumnsData = getCustomColumns(); + + // Calculate column widths based on whether we have custom columns + let columnWidths = { + id: fixedWidths.id, + createdAt: fixedWidths.id, + updatedAt: fixedWidths.id, + custom: minColumnWidth, + actions: hasCustomColumns ? fixedWidths.actions : 1387 + }; + + if (hasCustomColumns) { + const equalWidthColumns = [ + ...customColumnsData, + { id: '$createdAt' }, + { id: '$updatedAt' } + ]; + + const totalBaseWidth = + fixedWidths.id + fixedWidths.actions + equalWidthColumns.length * minColumnWidth; + + const viewportWidth = + spreadsheetContainer?.clientWidth || + (typeof window !== 'undefined' ? window.innerWidth : totalBaseWidth); + + const excessSpace = Math.max(0, viewportWidth - totalBaseWidth); + const extraPerColumn = + equalWidthColumns.length > 0 ? excessSpace / equalWidthColumns.length : 0; + const distributedWidth = minColumnWidth + extraPerColumn; + + columnWidths.createdAt = distributedWidth; + columnWidths.updatedAt = distributedWidth; + columnWidths.custom = distributedWidth; + } + + const columns: Column[] = [ + { + id: '$id', + title: '$id', + type: 'string', + width: columnWidths.id, + icon: IconFingerPrint, + ...baseColProps + } + ]; + + if (hasCustomColumns) { + columns.push( + ...customColumnsData.map((col) => ({ + ...col, + width: columnWidths.custom + })) + ); } - ]; - const getIndexesColumns = (): Column[] => - [ + columns.push( + { + id: '$createdAt', + title: '$createdAt', + type: 'datetime', + width: columnWidths.createdAt, + icon: IconCalendar, + ...baseColProps + }, + { + id: '$updatedAt', + title: '$updatedAt', + type: 'datetime', + width: columnWidths.updatedAt, + icon: IconCalendar, + ...baseColProps + }, + { + id: 'actions', + title: '', + type: 'string', + icon: IconPlus, + isAction: hasCustomColumns, + width: columnWidths.actions, + ...baseColProps + } + ); + + if (!hasCustomColumns) { + columns.push({ + id: 'empty', + title: '', + type: 'string', + ...baseColProps + }); + } + + return columns; + }; + + const getIndexesColumns = (): Column[] => { + const columns = [ { id: 'key', title: 'Key', icon: null, isPrimary: false }, { id: 'type', title: 'Type', icon: null, isPrimary: false }, - { id: 'columns', title: 'Columns', icon: null, isPrimary: false }, - { + { id: 'columns', title: 'Columns', icon: null, isPrimary: false } + ] as Column[]; + + if (!$isSmallViewport) { + columns.push({ id: 'empty', title: '', width: 40, isAction: true, isPrimary: false - } - ] as Column[]; + } as Column); + } + + return columns; + }; - const spreadsheetColumns = $derived(mode === 'rows' ? getRowColumns() : getIndexesColumns()); + const spreadsheetColumns = $derived(mode === 'indexes' ? getIndexesColumns() : getRowColumns()); const emptyCells = $derived( ($isSmallViewport ? 14 : $isTabletViewport ? 17 : 24) + (!$expandTabs ? 2 : 0) @@ -164,9 +248,10 @@
+ bind:this={spreadsheetContainer} + class:custom-columns={customColumns.length > 0} + class="databases-spreadsheet spreadsheet-container-outer"> {#each spreadsheetColumns as column (column.id)} - {@const columnActionsById = column.id === 'actions'} - -
{ - if (columnActionsById && mode === 'rows') { - $showCreateColumnSheet.show = true; - $showCreateColumnSheet.title = 'Create column'; - $showCreateColumnSheet.columns = $tableColumns; - $showCreateColumnSheet.columnsOrder = $columnsOrder; - } - }}> + {#if column.isAction} + + { + if (mode === 'rows') { + $showCreateColumnSheet.show = true; + $showCreateColumnSheet.title = 'Create column'; + $showCreateColumnSheet.columns = $tableColumns; + $showCreateColumnSheet.columnsOrder = $columnsOrder; + } + }}> + + + + {:else} {/if} -
+ {/if} {/each}
@@ -236,48 +324,36 @@ {#if !$spreadsheetLoading}
0} data-collapsed-tabs={!$expandTabs} + style:--overlay-left={overlayLeftOffset} + style:--overlay-top={overlayTopOffset} style:--dynamic-overlay-height={dynamicOverlayHeight}>
- - {title ?? `You have no ${mode} yet`} - - {#if showActions} - - {#if mode !== 'rows-filtered'} - - - {actions?.primary?.text ?? `Create ${mode}`} - + + + {title ?? `You have no ${mode} yet`} + + {@render subtitle?.()} + - {#if mode === 'rows'} - - {actions?.random?.text ?? `Generate sample data`} - + {#if showActions && actions} + {@const inline = mode === 'rows-filtered'} +
+ + {#if inline} + {@render actions?.()} + {:else} + + {@render actions?.()} + {/if} - {:else} - - {actions?.primary?.text} - - {/if} - + +
{/if}
@@ -291,6 +367,31 @@ position: fixed; overflow: hidden; + & :global(.spreadsheet-container) { + overflow-x: auto; + overflow-y: auto; + } + + & :global([data-select='true']) { + opacity: 0.85; + pointer-events: none; + } + + &.custom-columns { + width: unset; + } + + &:not(.custom-columns) :global(.spreadsheet-container) { + overflow-x: hidden; + overflow-y: hidden; + } + + /* alternative selector for header selection */ + & :global(.sticky-header [data-select='true']) { + opacity: 1; + pointer-events: none; + } + &[data-mode='rows'] { & :global([role='rowheader'] :nth-last-child(2) [role='presentation']) { display: none; @@ -298,6 +399,10 @@ } &[data-mode='indexes'] { + & :global([role='cell']:last-child [role='presentation']) { + display: none; + } + & :global([role='rowheader'] [role='cell']:nth-last-child(1)) { pointer-events: none; @@ -306,22 +411,14 @@ } } } - - & :global(.spreadsheet-container) { - overflow-x: hidden; - overflow-y: hidden; - } - - & :global([data-select='true']) { - opacity: 0.85; - pointer-events: none; - } } .spreadsheet-fade-bottom { + right: 0; bottom: 0; - width: 100%; position: fixed; + top: var(--overlay-top, auto); + left: var(--overlay-left, 0px); background: linear-gradient( 180deg, rgba(255, 255, 255, 0) 0%, @@ -330,17 +427,21 @@ ); z-index: 20; display: flex; + align-items: center; justify-content: center; transition: none !important; - height: var(--dynamic-overlay-height, 70.5vh); - - @media (max-width: 1024px) { - height: var(--dynamic-overlay-height, 63.35vh); + &.custom-columns { + pointer-events: none; } + } + + .controlled-width { + width: 100%; - @media (min-width: 1024px) { - height: var(--dynamic-overlay-height, 70.35vh); + @media (min-width: 1440px) { + width: 538px; + max-width: 538px; } } @@ -354,36 +455,12 @@ } .empty-actions { - left: 50%; - bottom: 35%; - position: fixed; - - @media (max-width: 768px) and (max-height: 768px) { - left: unset; - bottom: 12.5% !important; - } - - @media (max-width: 768px) and (max-height: 1024px) { - left: unset; - bottom: 15% !important; - } - - @media (max-width: 1024px) and (max-height: 1024px) { - left: unset; - bottom: 15%; - } + margin-bottom: 10%; + pointer-events: auto; @media (max-width: 1024px) { - left: unset; - bottom: 30%; - } - - @media (min-width: 1280px) { - bottom: 37.5%; - } - - @media (min-width: 1440px) { - bottom: 40%; + // experiment + margin-bottom: 15%; } } diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/layout/emptySheetCards.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/layout/emptySheetCards.svelte new file mode 100644 index 0000000000..f38edeb62f --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/layout/emptySheetCards.svelte @@ -0,0 +1,48 @@ + + + onClick?.()}> + + {#if icon} + + {/if} + + + + {title} + + {#if subtitle} + + {subtitle} + + {/if} + + + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/spreadsheet.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/spreadsheet.svelte index 5e3428bd47..ff67ac2279 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/spreadsheet.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/spreadsheet.svelte @@ -898,6 +898,7 @@ id={row?.$id} virtualItem={item} select={rowSelection} + hoverEffect showSelectOnHover valueWithoutHover={row.$sequence}> {#each $tableColumns as { id: columnId, isEditable } (columnId)} @@ -1159,7 +1160,8 @@ gap="xs" direction="row" alignItems="center" - alignContent="center"> + alignContent="center" + class="footer-input-select-wrapper"> Page