Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions src/lib/api/indexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
RAGTask,
RAGTaskInList,
RAGTaskStatus,
UserInfo,
} from "$lib/types";

/**
Expand Down Expand Up @@ -199,3 +200,23 @@ export const deleteFile = async (partition: string, file_id: string): Promise<bo
console.log(`File "${file_id}" deleted successfully.`);
return true;
};

/**
* Fetches current user information
*/
export const fetchUserInfo = async (): Promise<UserInfo> => {
console.log("Fetching user info...");
const response = await fetch(`${getApiBaseUrl()}/users/info`, {
headers: {
Authorization: `Bearer ${authToken.current}`,
},
});

if (!response.ok) {
throw new Error(`Failed to fetch user info: ${response.status} ${response.statusText}`);
}

const data = await response.json();
console.log("User info fetched: ", data);
return data;
};
58 changes: 56 additions & 2 deletions src/lib/components/indexer/Header.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,38 @@
import ChevronDown from "$lib/icons/ChevronDown.svelte";
import Upload from "$lib/icons/Upload.svelte";
import FileStorage from "$lib/icons/FileStorage.svelte";
import File from "$lib/icons/File.svelte";

/**
* Check if the quota is infinite (-1 from backend means infinite)
*/
let isQuotaInfinite = $derived(
indexerData.userInfo?.file_quota === -1
);

/**
* Check if uploads are disabled (quota reached and quota is not infinite)
*/
let uploadsDisabled = $derived(
indexerData.userInfo &&
!isQuotaInfinite &&
indexerData.userInfo.total_files >= indexerData.userInfo.file_quota
);

/**
* Check if quota is exceeded (for styling)
*/
let quotaExceeded = $derived(
indexerData.userInfo &&
!isQuotaInfinite &&
indexerData.userInfo.total_files >= indexerData.userInfo.file_quota
);
Comment thread
Ahmath-Gadji marked this conversation as resolved.

/**
* Opens the upload modal
*/
function openUploadModal() {
if (uploadsDisabled) return;
ui.showUploadModal = true;
}
</script>
Expand Down Expand Up @@ -45,12 +72,39 @@
{/if}
</div>

<!-- Action buttons -->
<!-- Action buttons and user info -->
<div class="flex items-center space-x-4">
<!-- User file quota info -->
{#if indexerData.userInfo}
<div class="flex items-center gap-4 text-base text-slate-600 border-r border-slate-300 pr-4">
<File className="size-6 fill-slate-400" />
<div class="flex flex-col">
<span class="text-sm text-slate-400">Files</span>
<span class="font-semibold text-lg">
{indexerData.userInfo.file_count} indexed
{#if indexerData.userInfo.pending_files > 0}
<span class="text-amber-500">+ {indexerData.userInfo.pending_files} pending</span>
{/if}
</span>
</div>
<div class="flex flex-col">
<span class="text-sm text-slate-400">Quota</span>
<span class="font-semibold text-lg {quotaExceeded ? 'text-red-500' : ''}">
{indexerData.userInfo.total_files} / {isQuotaInfinite ? '∞' : indexerData.userInfo.file_quota}
</span>
</div>
</div>
{/if}

<!-- Upload files -->
<button
class="flex cursor-pointer items-center gap-2 rounded-2xl border-none bg-linagora-500 px-4 py-2 font-semibold text-white hover:bg-linagora-600 focus:outline-none"
class="flex items-center gap-2 rounded-2xl border-none px-4 py-2 font-semibold text-white focus:outline-none
{uploadsDisabled
? 'bg-slate-400 cursor-not-allowed'
: 'bg-linagora-500 hover:bg-linagora-600 cursor-pointer'}"
onclick={openUploadModal}
disabled={uploadsDisabled}
title={uploadsDisabled ? 'File quota reached' : ''}
>
<Upload className="size-5 stroke-3" /> Upload files
</button>
Expand Down
113 changes: 111 additions & 2 deletions src/lib/components/indexer/UploadModal/UploadModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,68 @@
// UI properties
let showDropdown = $state(false);
let uploading = $state(false);
let quotaWarning: string | null = $state(null);

// UI elements
let dropdownRef: HTMLDivElement | undefined = $state();
let partitionLabelRef: HTMLLabelElement | undefined = $state();
let partitionButtonRef: HTMLButtonElement | undefined = $state();
let fileInputRef: HTMLInputElement | undefined = $state();

/**
* Check if quota is infinite (-1 means infinite)
*/
let isQuotaInfinite = $derived(
indexerData.userInfo?.file_quota === -1
);

/**
* Calculate remaining upload slots based on quota
* Returns Infinity if quota is infinite, otherwise returns remaining slots (minimum 0)
*/
let remainingQuota = $derived(
isQuotaInfinite
? Infinity
: Math.max(0, (indexerData.userInfo?.file_quota ?? 0) - (indexerData.userInfo?.total_files ?? 0))
Comment on lines +59 to +61

Copilot AI Feb 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remainingQuota falls back to 0 when indexerData.userInfo is still null (or if the user-info fetch fails). That makes the file picker disabled and prevents uploads even though the header still allows opening the modal. Consider treating a missing userInfo as "quota unknown" (e.g. allow selection / show a loading state) or disable opening the modal until userInfo is available so the behavior is consistent.

Suggested change
isQuotaInfinite
? Infinity
: Math.max(0, (indexerData.userInfo?.file_quota ?? 0) - (indexerData.userInfo?.total_files ?? 0))
indexerData.userInfo == null
? Infinity
: isQuotaInfinite
? Infinity
: Math.max(0, (indexerData.userInfo.file_quota ?? 0) - (indexerData.userInfo.total_files ?? 0))

Copilot uses AI. Check for mistakes.
);

/**
* Handle file selection and enforce quota limits
*/
function handleFileSelection(event: Event) {
const input = event.target as HTMLInputElement;
const selectedFiles = input.files;

if (!selectedFiles || selectedFiles.length === 0) {
files = undefined;
return;
}

// If quota is infinite, allow all files
if (isQuotaInfinite) {
files = selectedFiles;
return;
}

// If user selected more files than remaining quota, truncate and warn
if (selectedFiles.length > remainingQuota) {
quotaWarning = `You can only upload ${remainingQuota} more file${remainingQuota > 1 ? 's' : ''} due to your quota limit. Only the first ${remainingQuota} file${remainingQuota > 1 ? 's' : ''} will be uploaded.`;

// Create a new DataTransfer to hold the truncated file list
const dt = new DataTransfer();
for (let i = 0; i < remainingQuota; i++) {
dt.items.add(selectedFiles[i]);
}
files = dt.files;

// Update the input element to reflect the truncated selection
if (fileInputRef) {
fileInputRef.files = dt.files;
}
} else {
files = selectedFiles;
}
}

/**
* Upload the file(s) to a partition and close the modal once done.
Expand Down Expand Up @@ -155,6 +212,46 @@
});
</script>

<!-- Quota warning popup -->
{#if quotaWarning}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="fixed inset-0 z-[200] flex items-center justify-center bg-slate-500/30 backdrop-blur-xs"
onclick={() => quotaWarning = null}
role="dialog"
tabindex="-1"
>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="relative bg-white rounded-2xl shadow-xl p-6 max-w-md mx-4"
onclick={(e) => e.stopPropagation()}
role="alertdialog"
tabindex="-1"
>
<!-- Warning icon -->
<div class="flex items-center justify-center w-12 h-12 mx-auto mb-4 rounded-full bg-amber-100">
<svg class="size-6 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>

<!-- Title -->
<h3 class="text-lg font-semibold text-center text-slate-800 mb-2">Quota Limit Reached</h3>

<!-- Message -->
<p class="text-sm text-center text-slate-600 mb-6">{quotaWarning}</p>

<!-- OK Button -->
<button
class="w-full py-2 px-4 bg-linagora-500 hover:bg-linagora-600 text-white font-semibold rounded-xl cursor-pointer transition-colors"
onclick={() => quotaWarning = null}
>
OK
</button>
</div>
</div>
{/if}

<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="absolute top-0 left-0 z-100 h-screen w-screen bg-slate-500/20 backdrop-blur-xs"
Expand Down Expand Up @@ -247,9 +344,21 @@
<div class="relative flex flex-col">
<label class="mb-2 cursor-pointer font-medium" for="file-upload-btn"> Select one or multiple files </label>

<!-- Remaining quota info -->
{#if !isQuotaInfinite}
<p class="mb-2 text-sm {remainingQuota === 0 ? 'text-red-500' : 'text-slate-500'}">
{#if remainingQuota === 0}
You have reached your file quota limit.
{:else}
You can upload up to {remainingQuota} more file{remainingQuota > 1 ? 's' : ''}.
{/if}
</p>
{/if}

<label
class="group cursor-pointer rounded-3xl border-2 border-dashed border-slate-300 px-4 py-6
hover:border-linagora-300 hover:bg-linagora-50/30 focus:outline-none"
hover:border-linagora-300 hover:bg-linagora-50/30 focus:outline-none
{remainingQuota === 0 ? 'opacity-50 pointer-events-none' : ''}"
for="file-upload-btn"
>
{#if files}
Expand Down Expand Up @@ -286,7 +395,7 @@
{/if}
</label>

<input id="file-upload-btn" type="file" accept=".pdf" multiple bind:files class="hidden" />
<input id="file-upload-btn" type="file" accept=".pdf" multiple bind:this={fileInputRef} onchange={handleFileSelection} class="hidden" disabled={remainingQuota === 0} />
</div>

{#if files}
Expand Down
4 changes: 3 additions & 1 deletion src/lib/states.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/

// Import types
import type { RAGFile, RAGFileInList, RAGPartition, RAGTaskInList, Actor } from "$lib/types";
import type { RAGFile, RAGFileInList, RAGPartition, RAGTaskInList, Actor, UserInfo } from "$lib/types";

// UI States
export const ui: { showUploadModal: boolean; showLoginPage: boolean } = $state({
Expand All @@ -18,11 +18,13 @@ export const indexerData: {
currentPartition: { partition: RAGPartition | null; files: RAGFileInList[] };
currentFile: RAGFile | null;
tasks: RAGTaskInList[];
userInfo: UserInfo | null;
} = $state({
partitions: [], // List of all the partitions
currentPartition: { partition: null, files: [] }, // Current partition selected by the user
currentFile: null, // Current file selected by the user
tasks: [], // List of all the tasks
userInfo: null, // Current user information
});

export const dashboardData: {
Expand Down
3 changes: 2 additions & 1 deletion src/lib/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@
export * from "./partitions";
export * from "./tasks";
export * from "./files";
export * from "./actors";
export * from "./actors";
export * from "./user";
20 changes: 20 additions & 0 deletions src/lib/types/user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* This file contains all the types related to user information.
* @author Generated for LINAGORA
*/

import { ListFormat } from "typescript";

/**
* Represents user information (from the GET /users/info endpoint)
*/
export interface UserInfo {
id: string;
display_name: string;
is_admin: boolean;
memberships: ListFormat;
file_count: number;
Comment on lines +6 to +16

Copilot AI Feb 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file imports ListFormat from the typescript package as a runtime import. That will bloat the client bundle (and may fail to bundle) since typescript is a devDependency and not intended for browser runtime use. Replace memberships: ListFormat with an appropriate app-level type (e.g. string[]/Membership[]/unknown) and remove the typescript import (or at minimum make it import type).

Copilot uses AI. Check for mistakes.
pending_files: number;
total_files: number;
file_quota: number; // -1 means infinite quota
}
23 changes: 22 additions & 1 deletion src/routes/indexer/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,18 @@
// Properties
let { children } = $props();
let refreshTasksInterval: number | undefined;
let refreshUserInfoInterval: number | undefined;

/**
* Function to refresh user info periodically.
*/
async function refreshUserInfo() {
try {
indexerData.userInfo = await api.fetchUserInfo();
} catch (error) {
console.error("Failed to refresh user info:", error);
}
}

/**
* Function to refresh tasks periodically.
Expand Down Expand Up @@ -50,8 +62,16 @@
async function handleRouting() {
if (ui.showLoginPage) return;

// Load partitions
// Load partitions and user info
indexerData.partitions = await api.fetchPartitions();

// Fetch user info (for quota display)
try {
indexerData.userInfo = await api.fetchUserInfo();
refreshUserInfoInterval = setInterval(refreshUserInfo, 5000); // Refresh every 5 seconds
Comment thread
Ahmath-Gadji marked this conversation as resolved.
} catch (error) {
Comment thread
Ahmath-Gadji marked this conversation as resolved.
console.error("Failed to fetch user info:", error);
}

if (page.route.id === "/indexer") {
// Reset navigation states
Expand Down Expand Up @@ -110,6 +130,7 @@
console.log("Leaving indexer, clearing intervals.");
// Clear any intervals or timeouts here if needed
clearInterval(refreshTasksInterval);
clearInterval(refreshUserInfoInterval);
};
});
</script>
Expand Down