mirror of
https://github.com/yamadashy/repomix.git
synced 2025-06-11 00:25:54 +03:00
Merge pull request #642 from yamadashy/feat/refact-website
refactor(website/client): Modernize Vue.js architecture with composables
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,6 +3,7 @@ node_modules/
|
|||||||
|
|
||||||
# Build output
|
# Build output
|
||||||
lib/
|
lib/
|
||||||
|
website/client/.vitepress/dist/
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
@@ -1,13 +1,30 @@
|
|||||||
FROM node:23-alpine
|
# ==============================================================================
|
||||||
|
# Base stage
|
||||||
|
# ==============================================================================
|
||||||
|
FROM node:24-alpine AS base
|
||||||
|
|
||||||
|
# Install Git (required for VitePress)
|
||||||
RUN apk add --no-cache git
|
RUN apk add --no-cache git
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# Dependencies installation stage
|
||||||
|
# ==============================================================================
|
||||||
|
FROM base AS deps
|
||||||
|
|
||||||
|
# Copy package.json and package-lock.json
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
RUN npm i
|
# Install all dependencies (with npm cache optimization)
|
||||||
|
RUN npm ci && npm cache clean --force
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# Development stage
|
||||||
|
# ==============================================================================
|
||||||
|
FROM deps AS development
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
EXPOSE 5173
|
EXPOSE 5173
|
||||||
|
|||||||
@@ -58,16 +58,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TryItPackOptions
|
<TryItPackOptions
|
||||||
v-model:format="inputFormat"
|
v-model:format="packOptions.format"
|
||||||
v-model:include-patterns="inputIncludePatterns"
|
v-model:include-patterns="packOptions.includePatterns"
|
||||||
v-model:ignore-patterns="inputIgnorePatterns"
|
v-model:ignore-patterns="packOptions.ignorePatterns"
|
||||||
v-model:file-summary="inputFileSummary"
|
v-model:file-summary="packOptions.fileSummary"
|
||||||
v-model:directory-structure="inputDirectoryStructure"
|
v-model:directory-structure="packOptions.directoryStructure"
|
||||||
v-model:remove-comments="inputRemoveComments"
|
v-model:remove-comments="packOptions.removeComments"
|
||||||
v-model:remove-empty-lines="inputRemoveEmptyLines"
|
v-model:remove-empty-lines="packOptions.removeEmptyLines"
|
||||||
v-model:show-line-numbers="inputShowLineNumbers"
|
v-model:show-line-numbers="packOptions.showLineNumbers"
|
||||||
v-model:output-parsable="inputOutputParsable"
|
v-model:output-parsable="packOptions.outputParsable"
|
||||||
v-model:compress="inputCompress"
|
v-model:compress="packOptions.compress"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div v-if="hasExecuted">
|
<div v-if="hasExecuted">
|
||||||
@@ -84,9 +84,8 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { FolderArchive, FolderOpen, Link2 } from 'lucide-vue-next';
|
import { FolderArchive, FolderOpen, Link2 } from 'lucide-vue-next';
|
||||||
import { computed, nextTick, onMounted, ref } from 'vue';
|
import { nextTick, onMounted } from 'vue';
|
||||||
import type { PackResult } from '../api/client';
|
import { usePackRequest } from '../../composables/usePackRequest';
|
||||||
import { handlePackRequest } from '../utils/requestHandlers';
|
|
||||||
import { isValidRemoteValue } from '../utils/validation';
|
import { isValidRemoteValue } from '../utils/validation';
|
||||||
import PackButton from './PackButton.vue';
|
import PackButton from './PackButton.vue';
|
||||||
import TryItFileUpload from './TryItFileUpload.vue';
|
import TryItFileUpload from './TryItFileUpload.vue';
|
||||||
@@ -95,102 +94,34 @@ import TryItPackOptions from './TryItPackOptions.vue';
|
|||||||
import TryItResult from './TryItResult.vue';
|
import TryItResult from './TryItResult.vue';
|
||||||
import TryItUrlInput from './TryItUrlInput.vue';
|
import TryItUrlInput from './TryItUrlInput.vue';
|
||||||
|
|
||||||
// Form input states
|
// Use composables for state management
|
||||||
const inputUrl = ref('');
|
const {
|
||||||
const inputFormat = ref<'xml' | 'markdown' | 'plain'>('xml');
|
// Pack options
|
||||||
const inputRemoveComments = ref(false);
|
packOptions,
|
||||||
const inputRemoveEmptyLines = ref(false);
|
|
||||||
const inputShowLineNumbers = ref(false);
|
|
||||||
const inputFileSummary = ref(true);
|
|
||||||
const inputDirectoryStructure = ref(true);
|
|
||||||
const inputIncludePatterns = ref('');
|
|
||||||
const inputIgnorePatterns = ref('');
|
|
||||||
const inputOutputParsable = ref(false);
|
|
||||||
const inputRepositoryUrl = ref('');
|
|
||||||
const inputCompress = ref(false);
|
|
||||||
|
|
||||||
// Processing states
|
// Input states
|
||||||
const loading = ref(false);
|
inputUrl,
|
||||||
const error = ref<string | null>(null);
|
inputRepositoryUrl,
|
||||||
const result = ref<PackResult | null>(null);
|
mode,
|
||||||
const hasExecuted = ref(false);
|
uploadedFile,
|
||||||
const mode = ref<'url' | 'file' | 'folder'>('url');
|
|
||||||
const uploadedFile = ref<File | null>(null);
|
|
||||||
|
|
||||||
// Compute if the current mode's input is valid for submission
|
// Request states
|
||||||
const isSubmitValid = computed(() => {
|
loading,
|
||||||
switch (mode.value) {
|
error,
|
||||||
case 'url':
|
result,
|
||||||
return !!inputUrl.value && isValidRemoteValue(inputUrl.value.trim());
|
hasExecuted,
|
||||||
case 'file':
|
|
||||||
case 'folder':
|
|
||||||
return !!uploadedFile.value;
|
|
||||||
default:
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Explicitly set the mode and handle related state changes
|
// Computed
|
||||||
function setMode(newMode: 'url' | 'file' | 'folder') {
|
isSubmitValid,
|
||||||
mode.value = newMode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TIMEOUT_MS = 30_000;
|
// Actions
|
||||||
let requestController: AbortController | null = null;
|
setMode,
|
||||||
|
handleFileUpload,
|
||||||
|
submitRequest,
|
||||||
|
} = usePackRequest();
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
// Check if current mode has valid input
|
await submitRequest();
|
||||||
if (!isSubmitValid.value) return;
|
|
||||||
|
|
||||||
// Cancel any pending request
|
|
||||||
if (requestController) {
|
|
||||||
requestController.abort();
|
|
||||||
}
|
|
||||||
requestController = new AbortController();
|
|
||||||
|
|
||||||
loading.value = true;
|
|
||||||
error.value = null;
|
|
||||||
result.value = null;
|
|
||||||
hasExecuted.value = true;
|
|
||||||
inputRepositoryUrl.value = inputUrl.value;
|
|
||||||
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
if (requestController) {
|
|
||||||
requestController.abort('Request timed out');
|
|
||||||
throw new Error('Request timed out');
|
|
||||||
}
|
|
||||||
}, TIMEOUT_MS);
|
|
||||||
|
|
||||||
await handlePackRequest(
|
|
||||||
mode.value === 'url' ? inputUrl.value : '',
|
|
||||||
inputFormat.value,
|
|
||||||
{
|
|
||||||
removeComments: inputRemoveComments.value,
|
|
||||||
removeEmptyLines: inputRemoveEmptyLines.value,
|
|
||||||
showLineNumbers: inputShowLineNumbers.value,
|
|
||||||
fileSummary: inputFileSummary.value,
|
|
||||||
directoryStructure: inputDirectoryStructure.value,
|
|
||||||
includePatterns: inputIncludePatterns.value ? inputIncludePatterns.value.trim() : undefined,
|
|
||||||
ignorePatterns: inputIgnorePatterns.value ? inputIgnorePatterns.value.trim() : undefined,
|
|
||||||
outputParsable: inputOutputParsable.value,
|
|
||||||
compress: inputCompress.value,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess: (response) => {
|
|
||||||
result.value = response;
|
|
||||||
},
|
|
||||||
onError: (errorMessage) => {
|
|
||||||
error.value = errorMessage;
|
|
||||||
},
|
|
||||||
signal: requestController.signal,
|
|
||||||
file: mode.value === 'file' || mode.value === 'folder' ? uploadedFile.value || undefined : undefined,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
|
|
||||||
loading.value = false;
|
|
||||||
requestController = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeydown(event: KeyboardEvent) {
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
@@ -199,10 +130,6 @@ function handleKeydown(event: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFileUpload(file: File) {
|
|
||||||
uploadedFile.value = file;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add repository parameter handling when component mounts
|
// Add repository parameter handling when component mounts
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// Get URL parameters from window location
|
// Get URL parameters from window location
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { AlertTriangle, FolderArchive } from 'lucide-vue-next';
|
import { AlertTriangle, FolderArchive } from 'lucide-vue-next';
|
||||||
import { ref } from 'vue';
|
import { useFileUpload } from '../../composables/useFileUpload';
|
||||||
|
import { useZipProcessor } from '../../composables/useZipProcessor';
|
||||||
import PackButton from './PackButton.vue';
|
import PackButton from './PackButton.vue';
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
showButton?: boolean;
|
showButton?: boolean;
|
||||||
@@ -14,42 +13,50 @@ const emit = defineEmits<{
|
|||||||
upload: [file: File];
|
upload: [file: File];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const fileInput = ref<HTMLInputElement | null>(null);
|
const { validateZipFile } = useZipProcessor();
|
||||||
const dragActive = ref(false);
|
|
||||||
const selectedFile = ref<File | null>(null);
|
|
||||||
const errorMessage = ref<string | null>(null);
|
|
||||||
|
|
||||||
function validateFile(file: File): boolean {
|
const {
|
||||||
errorMessage.value = null;
|
fileInput,
|
||||||
|
dragActive,
|
||||||
|
selectedItem: selectedFile,
|
||||||
|
errorMessage,
|
||||||
|
hasError,
|
||||||
|
isValid,
|
||||||
|
inputAttributes,
|
||||||
|
handleFileSelect,
|
||||||
|
handleDragOver,
|
||||||
|
handleDragLeave,
|
||||||
|
handleDrop,
|
||||||
|
triggerFileInput,
|
||||||
|
clearSelection,
|
||||||
|
} = useFileUpload({
|
||||||
|
mode: 'file',
|
||||||
|
placeholder: 'Drop your ZIP file here or click to browse (max 10MB)',
|
||||||
|
icon: 'file',
|
||||||
|
options: {
|
||||||
|
maxFileSize: 10 * 1024 * 1024, // 10MB
|
||||||
|
acceptedTypes: ['.zip'],
|
||||||
|
accept: '.zip',
|
||||||
|
validateFile: validateZipFile,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (file.type !== 'application/zip' && !file.name.endsWith('.zip')) {
|
async function onFileSelect(files: FileList | null) {
|
||||||
errorMessage.value = 'Please upload a ZIP file';
|
const result = await handleFileSelect(files);
|
||||||
return false;
|
if (result.success && result.result) {
|
||||||
}
|
emit('upload', result.result);
|
||||||
|
|
||||||
if (file.size > MAX_FILE_SIZE) {
|
|
||||||
const sizeMB = (file.size / (1024 * 1024)).toFixed(1);
|
|
||||||
errorMessage.value = `File size (${sizeMB}MB) exceeds the 10MB limit`;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleFileSelect(files: FileList | null) {
|
|
||||||
if (!files || files.length === 0) return;
|
|
||||||
|
|
||||||
const file = files[0];
|
|
||||||
if (validateFile(file)) {
|
|
||||||
selectedFile.value = file;
|
|
||||||
emit('upload', file);
|
|
||||||
} else {
|
|
||||||
selectedFile.value = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function triggerFileInput() {
|
async function onDrop(event: DragEvent) {
|
||||||
fileInput.value?.click();
|
const result = await handleDrop(event);
|
||||||
|
if (result.success && result.result) {
|
||||||
|
emit('upload', result.result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFile() {
|
||||||
|
clearSelection();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -57,22 +64,21 @@ function triggerFileInput() {
|
|||||||
<div class="upload-wrapper">
|
<div class="upload-wrapper">
|
||||||
<div
|
<div
|
||||||
class="upload-container"
|
class="upload-container"
|
||||||
:class="{ 'drag-active': dragActive, 'has-error': errorMessage }"
|
:class="{ 'drag-active': dragActive, 'has-error': hasError }"
|
||||||
@dragover.prevent="dragActive = true"
|
@dragover.prevent="handleDragOver"
|
||||||
@dragleave="dragActive = false"
|
@dragleave="handleDragLeave"
|
||||||
@drop.prevent="handleFileSelect($event.dataTransfer?.files || null)"
|
@drop.prevent="onDrop"
|
||||||
@click="triggerFileInput"
|
@click="triggerFileInput"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
ref="fileInput"
|
ref="fileInput"
|
||||||
type="file"
|
v-bind="inputAttributes"
|
||||||
accept=".zip"
|
|
||||||
class="hidden-input"
|
class="hidden-input"
|
||||||
@change="(e) => handleFileSelect((e.target as HTMLInputElement).files)"
|
@change="(e) => onFileSelect((e.target as HTMLInputElement).files)"
|
||||||
/>
|
/>
|
||||||
<div class="upload-content">
|
<div class="upload-content">
|
||||||
<div class="upload-icon">
|
<div class="upload-icon">
|
||||||
<AlertTriangle v-if="errorMessage" class="icon-error" size="20" />
|
<AlertTriangle v-if="hasError" class="icon-error" size="20" />
|
||||||
<FolderArchive v-else class="icon-folder" size="20" />
|
<FolderArchive v-else class="icon-folder" size="20" />
|
||||||
</div>
|
</div>
|
||||||
<div class="upload-text">
|
<div class="upload-text">
|
||||||
@@ -80,8 +86,8 @@ function triggerFileInput() {
|
|||||||
{{ errorMessage }}
|
{{ errorMessage }}
|
||||||
</p>
|
</p>
|
||||||
<p v-else-if="selectedFile" class="selected-file">
|
<p v-else-if="selectedFile" class="selected-file">
|
||||||
Selected: {{ selectedFile.name }}
|
Selected: {{ selectedFile }}
|
||||||
<button class="clear-button" @click.stop="selectedFile = null">×</button>
|
<button class="clear-button" @click.stop="clearFile">×</button>
|
||||||
</p>
|
</p>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<p>Drop your ZIP file here or click to browse (max 10MB)</p>
|
<p>Drop your ZIP file here or click to browse (max 10MB)</p>
|
||||||
@@ -93,7 +99,7 @@ function triggerFileInput() {
|
|||||||
<div v-if="showButton" class="pack-button-container">
|
<div v-if="showButton" class="pack-button-container">
|
||||||
<PackButton
|
<PackButton
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:isValid="!!selectedFile"
|
:isValid="isValid"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import JSZip from 'jszip';
|
|
||||||
import { AlertTriangle, FolderOpen } from 'lucide-vue-next';
|
import { AlertTriangle, FolderOpen } from 'lucide-vue-next';
|
||||||
import { ref } from 'vue';
|
import { useFileUpload } from '../../composables/useFileUpload';
|
||||||
|
import { useZipProcessor } from '../../composables/useZipProcessor';
|
||||||
import PackButton from './PackButton.vue';
|
import PackButton from './PackButton.vue';
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
showButton?: boolean;
|
showButton?: boolean;
|
||||||
@@ -15,185 +13,82 @@ const emit = defineEmits<{
|
|||||||
upload: [file: File];
|
upload: [file: File];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const fileInput = ref<HTMLInputElement | null>(null);
|
const { createZipFromFiles } = useZipProcessor();
|
||||||
const dragActive = ref(false);
|
|
||||||
const selectedFolder = ref<string | null>(null);
|
|
||||||
const errorMessage = ref<string | null>(null);
|
|
||||||
|
|
||||||
// Common validation logic
|
const {
|
||||||
const validateFolder = (files: File[]): boolean => {
|
fileInput,
|
||||||
errorMessage.value = null;
|
dragActive,
|
||||||
|
selectedItem: selectedFolder,
|
||||||
if (files.length === 0) {
|
errorMessage,
|
||||||
errorMessage.value = 'The folder is empty.';
|
hasError,
|
||||||
return false;
|
isValid,
|
||||||
}
|
inputAttributes,
|
||||||
|
handleFileSelect,
|
||||||
const totalSize = files.reduce((sum, file) => sum + file.size, 0);
|
handleDragOver,
|
||||||
if (totalSize > MAX_FILE_SIZE) {
|
handleDragLeave,
|
||||||
const sizeMB = (totalSize / (1024 * 1024)).toFixed(1);
|
handleDrop,
|
||||||
errorMessage.value = `File size (${sizeMB}MB) exceeds the 10MB limit`;
|
triggerFileInput,
|
||||||
return false;
|
clearSelection,
|
||||||
}
|
} = useFileUpload({
|
||||||
|
mode: 'folder',
|
||||||
return true;
|
placeholder: 'Drop your folder here or click to browse (max 10MB)',
|
||||||
};
|
icon: 'folder',
|
||||||
|
options: {
|
||||||
// Create ZIP file
|
maxFileSize: 10 * 1024 * 1024, // 10MB
|
||||||
const createZipFile = async (files: File[], folderName: string): Promise<File> => {
|
webkitdirectory: true,
|
||||||
const zip = new JSZip();
|
validateFiles: (files: File[]) => {
|
||||||
|
if (files.length === 0) {
|
||||||
for (const file of files) {
|
return { valid: false, error: 'The folder is empty.' };
|
||||||
const path = file.webkitRelativePath || file.name;
|
|
||||||
zip.file(path, file);
|
|
||||||
}
|
|
||||||
|
|
||||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
|
||||||
return new File([zipBlob], `${folderName}.zip`);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Common folder processing logic
|
|
||||||
const processFolder = async (files: File[], folderName: string): Promise<void> => {
|
|
||||||
if (!validateFolder(files)) {
|
|
||||||
selectedFolder.value = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const zipFile = await createZipFile(files, folderName);
|
|
||||||
selectedFolder.value = folderName;
|
|
||||||
emit('upload', zipFile);
|
|
||||||
};
|
|
||||||
|
|
||||||
// File selection handler
|
|
||||||
const handleFileSelect = async (files: FileList | null): Promise<void> => {
|
|
||||||
if (!files || files.length === 0) return;
|
|
||||||
|
|
||||||
const fileArray = Array.from(files);
|
|
||||||
const folderName = files[0].webkitRelativePath.split('/')[0];
|
|
||||||
|
|
||||||
await processFolder(fileArray, folderName);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Folder drop handler
|
|
||||||
const handleFolderDrop = async (event: DragEvent): Promise<void> => {
|
|
||||||
event.preventDefault();
|
|
||||||
dragActive.value = false;
|
|
||||||
errorMessage.value = null;
|
|
||||||
|
|
||||||
if (!event.dataTransfer?.items?.length) return;
|
|
||||||
|
|
||||||
// Check directory reading capability
|
|
||||||
if (!('webkitGetAsEntry' in DataTransferItem.prototype)) {
|
|
||||||
errorMessage.value = "Your browser doesn't support folder drop. Please use the browse button instead.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const entry = event.dataTransfer.items[0].webkitGetAsEntry();
|
|
||||||
if (!entry?.isDirectory) {
|
|
||||||
errorMessage.value = 'Please drop a folder, not a file.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const folderName = entry.name;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const files = await collectFilesFromEntry(entry);
|
|
||||||
await processFolder(files, folderName);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error processing dropped folder:', error);
|
|
||||||
errorMessage.value = 'Failed to process the folder. Please try again or use the browse button.';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const isFileEntry = (entry: FileSystemEntry): entry is FileSystemFileEntry => {
|
|
||||||
return entry.isFile;
|
|
||||||
};
|
|
||||||
const isDirectoryEntry = (entry: FileSystemEntry): entry is FileSystemDirectoryEntry => {
|
|
||||||
return entry.isDirectory;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Recursively collect files from entry
|
|
||||||
const collectFilesFromEntry = async (entry: FileSystemEntry, path = ''): Promise<File[]> => {
|
|
||||||
if (isFileEntry(entry)) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
entry.file((file: File) => {
|
|
||||||
// Create custom file with path information
|
|
||||||
const customFile = new File([file], file.name, {
|
|
||||||
type: file.type,
|
|
||||||
lastModified: file.lastModified,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add relative path information
|
|
||||||
Object.defineProperty(customFile, 'webkitRelativePath', {
|
|
||||||
value: path ? `${path}/${file.name}` : file.name,
|
|
||||||
});
|
|
||||||
|
|
||||||
resolve([customFile]);
|
|
||||||
}, reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isDirectoryEntry(entry) && entry.createReader) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const dirReader = entry.createReader();
|
|
||||||
const allFiles: File[] = [];
|
|
||||||
|
|
||||||
// Function to read entries in directory
|
|
||||||
function readEntries() {
|
|
||||||
dirReader.readEntries(async (entries: FileSystemEntry[]) => {
|
|
||||||
if (entries.length === 0) {
|
|
||||||
resolve(allFiles);
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
// Process each entry
|
|
||||||
for (const childEntry of entries) {
|
|
||||||
const newPath = path ? `${path}/${childEntry.name}` : childEntry.name;
|
|
||||||
const files = await collectFilesFromEntry(childEntry, newPath);
|
|
||||||
allFiles.push(...files);
|
|
||||||
}
|
|
||||||
// Continue reading (some browsers return entries in batches)
|
|
||||||
readEntries();
|
|
||||||
} catch (error) {
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, reject);
|
|
||||||
}
|
}
|
||||||
|
return { valid: true };
|
||||||
|
},
|
||||||
|
preprocessFiles: async (files: File[], folderName?: string) => {
|
||||||
|
if (!folderName) {
|
||||||
|
throw new Error('Folder name is required');
|
||||||
|
}
|
||||||
|
return await createZipFromFiles(files, folderName);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
readEntries();
|
async function onFileSelect(files: FileList | null) {
|
||||||
});
|
const result = await handleFileSelect(files);
|
||||||
|
if (result.success && result.result) {
|
||||||
|
emit('upload', result.result);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return []; // If neither file nor directory
|
async function onDrop(event: DragEvent) {
|
||||||
};
|
const result = await handleDrop(event);
|
||||||
|
if (result.success && result.result) {
|
||||||
|
emit('upload', result.result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const triggerFileInput = () => {
|
function clearFolder() {
|
||||||
fileInput.value?.click();
|
clearSelection();
|
||||||
};
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="upload-wrapper">
|
<div class="upload-wrapper">
|
||||||
<div
|
<div
|
||||||
class="upload-container"
|
class="upload-container"
|
||||||
:class="{ 'drag-active': dragActive, 'has-error': errorMessage }"
|
:class="{ 'drag-active': dragActive, 'has-error': hasError }"
|
||||||
@dragover.prevent="dragActive = true"
|
@dragover.prevent="handleDragOver"
|
||||||
@dragleave="dragActive = false"
|
@dragleave="handleDragLeave"
|
||||||
@drop.prevent="handleFolderDrop($event)"
|
@drop.prevent="onDrop"
|
||||||
@click="triggerFileInput"
|
@click="triggerFileInput"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
ref="fileInput"
|
ref="fileInput"
|
||||||
type="file"
|
v-bind="inputAttributes"
|
||||||
directory
|
|
||||||
webkitdirectory
|
|
||||||
mozdirectory
|
|
||||||
class="hidden-input"
|
class="hidden-input"
|
||||||
@change="(e) => handleFileSelect((e.target as HTMLInputElement).files)"
|
@change="(e) => onFileSelect((e.target as HTMLInputElement).files)"
|
||||||
/>
|
/>
|
||||||
<div class="upload-content">
|
<div class="upload-content">
|
||||||
<div class="upload-icon">
|
<div class="upload-icon">
|
||||||
<AlertTriangle v-if="errorMessage" class="icon-error" size="20" />
|
<AlertTriangle v-if="hasError" class="icon-error" size="20" />
|
||||||
<FolderOpen v-else class="icon-folder" size="20" />
|
<FolderOpen v-else class="icon-folder" size="20" />
|
||||||
</div>
|
</div>
|
||||||
<div class="upload-text">
|
<div class="upload-text">
|
||||||
@@ -202,7 +97,7 @@ const triggerFileInput = () => {
|
|||||||
</p>
|
</p>
|
||||||
<p v-else-if="selectedFolder" class="selected-file">
|
<p v-else-if="selectedFolder" class="selected-file">
|
||||||
Selected: {{ selectedFolder }}
|
Selected: {{ selectedFolder }}
|
||||||
<button class="clear-button" @click.stop="selectedFolder = null">×</button>
|
<button class="clear-button" @click.stop="clearFolder">×</button>
|
||||||
</p>
|
</p>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<p>Drop your folder here or click to browse (max 10MB)</p>
|
<p>Drop your folder here or click to browse (max 10MB)</p>
|
||||||
@@ -214,7 +109,7 @@ const triggerFileInput = () => {
|
|||||||
<div v-if="showButton" class="pack-button-container">
|
<div v-if="showButton" class="pack-button-container">
|
||||||
<PackButton
|
<PackButton
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:isValid="!!selectedFolder"
|
:isValid="isValid"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
358
website/client/composables/useFileUpload.ts
Normal file
358
website/client/composables/useFileUpload.ts
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
export interface FileUploadOptions {
|
||||||
|
maxFileSize?: number;
|
||||||
|
acceptedTypes?: string[];
|
||||||
|
accept?: string;
|
||||||
|
multiple?: boolean;
|
||||||
|
webkitdirectory?: boolean;
|
||||||
|
validateFile?: (file: File) => { valid: boolean; error?: string };
|
||||||
|
validateFiles?: (files: File[]) => { valid: boolean; error?: string };
|
||||||
|
preprocessFiles?: (files: File[], folderName?: string) => Promise<File>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileUploadConfig {
|
||||||
|
mode: 'file' | 'folder';
|
||||||
|
placeholder: string;
|
||||||
|
icon: 'file' | 'folder';
|
||||||
|
options: FileUploadOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFileUpload(config: FileUploadConfig) {
|
||||||
|
const {
|
||||||
|
maxFileSize = 10 * 1024 * 1024, // 10MB default
|
||||||
|
acceptedTypes = [],
|
||||||
|
accept = '',
|
||||||
|
multiple = false,
|
||||||
|
webkitdirectory = false,
|
||||||
|
validateFile,
|
||||||
|
validateFiles,
|
||||||
|
preprocessFiles,
|
||||||
|
} = config.options;
|
||||||
|
|
||||||
|
// Reactive state
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(null);
|
||||||
|
const dragActive = ref(false);
|
||||||
|
const selectedItem = ref<string | null>(null);
|
||||||
|
const errorMessage = ref<string | null>(null);
|
||||||
|
const isProcessing = ref(false);
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const hasError = computed(() => !!errorMessage.value);
|
||||||
|
const hasSelection = computed(() => !!selectedItem.value);
|
||||||
|
const isValid = computed(() => hasSelection.value && !hasError.value);
|
||||||
|
|
||||||
|
// Default file validation
|
||||||
|
function defaultValidateFile(file: File): { valid: boolean; error?: string } {
|
||||||
|
if (acceptedTypes.length > 0) {
|
||||||
|
const isValidType = acceptedTypes.some((type) => {
|
||||||
|
if (type.startsWith('.')) {
|
||||||
|
return file.name.toLowerCase().endsWith(type.toLowerCase());
|
||||||
|
}
|
||||||
|
return file.type === type;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isValidType) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `Please upload a ${acceptedTypes.join(' or ')} file`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > maxFileSize) {
|
||||||
|
const sizeMB = (file.size / (1024 * 1024)).toFixed(1);
|
||||||
|
const limitMB = (maxFileSize / (1024 * 1024)).toFixed(0);
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `File size (${sizeMB}MB) exceeds the ${limitMB}MB limit`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default files validation (for folder/multiple files)
|
||||||
|
function defaultValidateFiles(files: File[]): { valid: boolean; error?: string } {
|
||||||
|
if (files.length === 0) {
|
||||||
|
return { valid: false, error: 'No files found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalSize = files.reduce((sum, file) => sum + file.size, 0);
|
||||||
|
if (totalSize > maxFileSize) {
|
||||||
|
const sizeMB = (totalSize / (1024 * 1024)).toFixed(1);
|
||||||
|
const limitMB = (maxFileSize / (1024 * 1024)).toFixed(0);
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `Total size (${sizeMB}MB) exceeds the ${limitMB}MB limit`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear error and selection
|
||||||
|
function clearError() {
|
||||||
|
errorMessage.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelection() {
|
||||||
|
selectedItem.value = null;
|
||||||
|
errorMessage.value = null;
|
||||||
|
// Clear file input to prevent re-selection issues
|
||||||
|
if (fileInput.value) {
|
||||||
|
fileInput.value.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate and process files
|
||||||
|
async function processFiles(
|
||||||
|
files: File[],
|
||||||
|
folderName?: string,
|
||||||
|
): Promise<{ success: boolean; result?: File; error?: string }> {
|
||||||
|
clearError();
|
||||||
|
isProcessing.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validation
|
||||||
|
const validator =
|
||||||
|
config.mode === 'folder' || multiple
|
||||||
|
? validateFiles || defaultValidateFiles
|
||||||
|
: validateFile || defaultValidateFile;
|
||||||
|
|
||||||
|
let validationResult: { valid: boolean; error?: string };
|
||||||
|
if (config.mode === 'folder' || multiple) {
|
||||||
|
validationResult = (validator as typeof defaultValidateFiles)(files);
|
||||||
|
} else {
|
||||||
|
validationResult = (validator as typeof defaultValidateFile)(files[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validationResult.valid) {
|
||||||
|
errorMessage.value = validationResult.error || 'Validation failed';
|
||||||
|
return { success: false, error: validationResult.error };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preprocessing (e.g., ZIP creation for folders)
|
||||||
|
let resultFile: File;
|
||||||
|
if (preprocessFiles) {
|
||||||
|
resultFile = await preprocessFiles(files, folderName);
|
||||||
|
} else {
|
||||||
|
if ((config.mode === 'folder' || multiple) && files.length > 1) {
|
||||||
|
throw new Error('Multiple files require a preprocessor function');
|
||||||
|
}
|
||||||
|
resultFile = files[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update selection
|
||||||
|
selectedItem.value = folderName || resultFile.name;
|
||||||
|
|
||||||
|
// Clear file input to prevent re-selection issues
|
||||||
|
if (fileInput.value) {
|
||||||
|
fileInput.value.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, result: resultFile };
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = error instanceof Error ? error.message : 'Processing failed';
|
||||||
|
errorMessage.value = errorMsg;
|
||||||
|
return { success: false, error: errorMsg };
|
||||||
|
} finally {
|
||||||
|
isProcessing.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle file input selection
|
||||||
|
async function handleFileSelect(
|
||||||
|
files: FileList | null,
|
||||||
|
): Promise<{ success: boolean; result?: File; error?: string }> {
|
||||||
|
if (!files || files.length === 0) {
|
||||||
|
return { success: false, error: 'No files selected' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileArray = Array.from(files);
|
||||||
|
let folderName: string | undefined;
|
||||||
|
|
||||||
|
if (config.mode === 'folder' && files[0].webkitRelativePath) {
|
||||||
|
folderName = files[0].webkitRelativePath.split('/')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return await processFiles(fileArray, folderName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle drag and drop
|
||||||
|
function handleDragOver(event: DragEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
dragActive.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragLeave() {
|
||||||
|
dragActive.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDrop(event: DragEvent): Promise<{ success: boolean; result?: File; error?: string }> {
|
||||||
|
event.preventDefault();
|
||||||
|
dragActive.value = false;
|
||||||
|
|
||||||
|
if (config.mode === 'folder') {
|
||||||
|
return await handleFolderDrop(event);
|
||||||
|
}
|
||||||
|
return await handleFileSelect(event.dataTransfer?.files || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Specialized folder drop handling
|
||||||
|
async function handleFolderDrop(event: DragEvent): Promise<{ success: boolean; result?: File; error?: string }> {
|
||||||
|
if (!event.dataTransfer?.items?.length) {
|
||||||
|
return { success: false, error: 'No items found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check directory reading capability
|
||||||
|
if (!('webkitGetAsEntry' in DataTransferItem.prototype)) {
|
||||||
|
const error = "Your browser doesn't support folder drop. Please use the browse button instead.";
|
||||||
|
errorMessage.value = error;
|
||||||
|
return { success: false, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = event.dataTransfer.items[0].webkitGetAsEntry();
|
||||||
|
if (!entry?.isDirectory) {
|
||||||
|
const error = 'Please drop a folder, not a file.';
|
||||||
|
errorMessage.value = error;
|
||||||
|
return { success: false, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const files = await collectFilesFromEntry(entry);
|
||||||
|
return await processFiles(files, entry.name);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = 'Failed to process the folder. Please try again or use the browse button.';
|
||||||
|
errorMessage.value = errorMsg;
|
||||||
|
return { success: false, error: errorMsg };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constants for safety limits
|
||||||
|
const MAX_DEPTH = 20;
|
||||||
|
const MAX_FILES = 10000;
|
||||||
|
|
||||||
|
// Helper functions for folder processing
|
||||||
|
async function collectFilesFromEntry(
|
||||||
|
entry: FileSystemEntry,
|
||||||
|
path = '',
|
||||||
|
depth = 0,
|
||||||
|
fileCount = { current: 0 },
|
||||||
|
): Promise<File[]> {
|
||||||
|
// Check depth limit
|
||||||
|
if (depth > MAX_DEPTH) {
|
||||||
|
throw new Error(`Directory structure too deep (max depth: ${MAX_DEPTH})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file count limit
|
||||||
|
if (fileCount.current > MAX_FILES) {
|
||||||
|
throw new Error(`Too many files in directory structure (max files: ${MAX_FILES})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.isFile) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
(entry as FileSystemFileEntry).file((file: File) => {
|
||||||
|
// Check file count before adding
|
||||||
|
if (fileCount.current >= MAX_FILES) {
|
||||||
|
reject(new Error(`Too many files in directory structure (max files: ${MAX_FILES})`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fileCount.current++;
|
||||||
|
|
||||||
|
const customFile = new File([file], file.name, {
|
||||||
|
type: file.type,
|
||||||
|
lastModified: file.lastModified,
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(customFile, 'webkitRelativePath', {
|
||||||
|
value: path ? `${path}/${file.name}` : file.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
resolve([customFile]);
|
||||||
|
}, reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.isDirectory && (entry as FileSystemDirectoryEntry).createReader) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const dirReader = (entry as FileSystemDirectoryEntry).createReader();
|
||||||
|
const allFiles: File[] = [];
|
||||||
|
|
||||||
|
function readEntries() {
|
||||||
|
dirReader.readEntries(async (entries: FileSystemEntry[]) => {
|
||||||
|
if (entries.length === 0) {
|
||||||
|
resolve(allFiles);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
for (const childEntry of entries) {
|
||||||
|
// Check limits before processing each entry
|
||||||
|
if (depth + 1 > MAX_DEPTH) {
|
||||||
|
throw new Error(`Directory structure too deep (max depth: ${MAX_DEPTH})`);
|
||||||
|
}
|
||||||
|
if (fileCount.current > MAX_FILES) {
|
||||||
|
throw new Error(`Too many files in directory structure (max files: ${MAX_FILES})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newPath = path ? `${path}/${childEntry.name}` : childEntry.name;
|
||||||
|
const files = await collectFilesFromEntry(childEntry, newPath, depth + 1, fileCount);
|
||||||
|
allFiles.push(...files);
|
||||||
|
}
|
||||||
|
readEntries();
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, reject);
|
||||||
|
}
|
||||||
|
|
||||||
|
readEntries();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger file input
|
||||||
|
function triggerFileInput() {
|
||||||
|
fileInput.value?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input attributes for template
|
||||||
|
const inputAttributes = computed(() => ({
|
||||||
|
type: 'file',
|
||||||
|
accept,
|
||||||
|
multiple,
|
||||||
|
webkitdirectory: webkitdirectory || config.mode === 'folder',
|
||||||
|
...(config.mode === 'folder' && {
|
||||||
|
directory: true,
|
||||||
|
mozdirectory: true,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Refs
|
||||||
|
fileInput,
|
||||||
|
dragActive,
|
||||||
|
selectedItem,
|
||||||
|
errorMessage,
|
||||||
|
isProcessing,
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
hasError,
|
||||||
|
hasSelection,
|
||||||
|
isValid,
|
||||||
|
inputAttributes,
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
handleFileSelect,
|
||||||
|
handleDragOver,
|
||||||
|
handleDragLeave,
|
||||||
|
handleDrop,
|
||||||
|
triggerFileInput,
|
||||||
|
clearError,
|
||||||
|
clearSelection,
|
||||||
|
processFiles,
|
||||||
|
};
|
||||||
|
}
|
||||||
58
website/client/composables/usePackOptions.ts
Normal file
58
website/client/composables/usePackOptions.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { computed, reactive } from 'vue';
|
||||||
|
|
||||||
|
export interface PackOptions {
|
||||||
|
format: 'xml' | 'markdown' | 'plain';
|
||||||
|
removeComments: boolean;
|
||||||
|
removeEmptyLines: boolean;
|
||||||
|
showLineNumbers: boolean;
|
||||||
|
fileSummary: boolean;
|
||||||
|
directoryStructure: boolean;
|
||||||
|
includePatterns: string;
|
||||||
|
ignorePatterns: string;
|
||||||
|
outputParsable: boolean;
|
||||||
|
compress: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PACK_OPTIONS: PackOptions = {
|
||||||
|
format: 'xml',
|
||||||
|
removeComments: false,
|
||||||
|
removeEmptyLines: false,
|
||||||
|
showLineNumbers: false,
|
||||||
|
fileSummary: true,
|
||||||
|
directoryStructure: true,
|
||||||
|
includePatterns: '',
|
||||||
|
ignorePatterns: '',
|
||||||
|
outputParsable: false,
|
||||||
|
compress: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function usePackOptions() {
|
||||||
|
const packOptions = reactive<PackOptions>({ ...DEFAULT_PACK_OPTIONS });
|
||||||
|
|
||||||
|
const getPackRequestOptions = computed(() => ({
|
||||||
|
removeComments: packOptions.removeComments,
|
||||||
|
removeEmptyLines: packOptions.removeEmptyLines,
|
||||||
|
showLineNumbers: packOptions.showLineNumbers,
|
||||||
|
fileSummary: packOptions.fileSummary,
|
||||||
|
directoryStructure: packOptions.directoryStructure,
|
||||||
|
includePatterns: packOptions.includePatterns ? packOptions.includePatterns.trim() : undefined,
|
||||||
|
ignorePatterns: packOptions.ignorePatterns ? packOptions.ignorePatterns.trim() : undefined,
|
||||||
|
outputParsable: packOptions.outputParsable,
|
||||||
|
compress: packOptions.compress,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function updateOption<K extends keyof PackOptions>(key: K, value: PackOptions[K]) {
|
||||||
|
packOptions[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetOptions() {
|
||||||
|
Object.assign(packOptions, DEFAULT_PACK_OPTIONS);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
packOptions,
|
||||||
|
getPackRequestOptions,
|
||||||
|
updateOption,
|
||||||
|
resetOptions,
|
||||||
|
};
|
||||||
|
}
|
||||||
134
website/client/composables/usePackRequest.ts
Normal file
134
website/client/composables/usePackRequest.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import type { PackResult } from '../components/api/client';
|
||||||
|
import { handlePackRequest } from '../components/utils/requestHandlers';
|
||||||
|
import { isValidRemoteValue } from '../components/utils/validation';
|
||||||
|
import { usePackOptions } from './usePackOptions';
|
||||||
|
|
||||||
|
export type InputMode = 'url' | 'file' | 'folder';
|
||||||
|
|
||||||
|
export function usePackRequest() {
|
||||||
|
const packOptionsComposable = usePackOptions();
|
||||||
|
const { packOptions, getPackRequestOptions } = packOptionsComposable;
|
||||||
|
|
||||||
|
// Input states
|
||||||
|
const inputUrl = ref('');
|
||||||
|
const inputRepositoryUrl = ref('');
|
||||||
|
const mode = ref<InputMode>('url');
|
||||||
|
const uploadedFile = ref<File | null>(null);
|
||||||
|
|
||||||
|
// Request states
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
const result = ref<PackResult | null>(null);
|
||||||
|
const hasExecuted = ref(false);
|
||||||
|
|
||||||
|
// Request controller for cancellation
|
||||||
|
let requestController: AbortController | null = null;
|
||||||
|
const TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
|
// Computed validation
|
||||||
|
const isSubmitValid = computed(() => {
|
||||||
|
switch (mode.value) {
|
||||||
|
case 'url':
|
||||||
|
return !!inputUrl.value && isValidRemoteValue(inputUrl.value.trim());
|
||||||
|
case 'file':
|
||||||
|
case 'folder':
|
||||||
|
return !!uploadedFile.value;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function setMode(newMode: InputMode) {
|
||||||
|
mode.value = newMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileUpload(file: File) {
|
||||||
|
uploadedFile.value = file;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetRequest() {
|
||||||
|
error.value = null;
|
||||||
|
result.value = null;
|
||||||
|
hasExecuted.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitRequest() {
|
||||||
|
if (!isSubmitValid.value) return;
|
||||||
|
|
||||||
|
// Cancel any pending request
|
||||||
|
if (requestController) {
|
||||||
|
requestController.abort();
|
||||||
|
}
|
||||||
|
requestController = new AbortController();
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
result.value = null;
|
||||||
|
hasExecuted.value = true;
|
||||||
|
inputRepositoryUrl.value = inputUrl.value;
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
if (requestController) {
|
||||||
|
requestController.abort('Request timed out');
|
||||||
|
}
|
||||||
|
}, TIMEOUT_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handlePackRequest(
|
||||||
|
mode.value === 'url' ? inputUrl.value : '',
|
||||||
|
packOptions.format,
|
||||||
|
getPackRequestOptions.value,
|
||||||
|
{
|
||||||
|
onSuccess: (response) => {
|
||||||
|
result.value = response;
|
||||||
|
},
|
||||||
|
onError: (errorMessage) => {
|
||||||
|
error.value = errorMessage;
|
||||||
|
},
|
||||||
|
signal: requestController.signal,
|
||||||
|
file: mode.value === 'file' || mode.value === 'folder' ? uploadedFile.value || undefined : undefined,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
loading.value = false;
|
||||||
|
requestController = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelRequest() {
|
||||||
|
if (requestController) {
|
||||||
|
requestController.abort();
|
||||||
|
requestController = null;
|
||||||
|
}
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Pack options (re-exported for convenience)
|
||||||
|
...packOptionsComposable,
|
||||||
|
|
||||||
|
// Input states
|
||||||
|
inputUrl,
|
||||||
|
inputRepositoryUrl,
|
||||||
|
mode,
|
||||||
|
uploadedFile,
|
||||||
|
|
||||||
|
// Request states
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
result,
|
||||||
|
hasExecuted,
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
isSubmitValid,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setMode,
|
||||||
|
handleFileUpload,
|
||||||
|
resetRequest,
|
||||||
|
submitRequest,
|
||||||
|
cancelRequest,
|
||||||
|
};
|
||||||
|
}
|
||||||
31
website/client/composables/useZipProcessor.ts
Normal file
31
website/client/composables/useZipProcessor.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import JSZip from 'jszip';
|
||||||
|
|
||||||
|
export function useZipProcessor() {
|
||||||
|
async function createZipFromFiles(files: File[], folderName: string): Promise<File> {
|
||||||
|
try {
|
||||||
|
const zip = new JSZip();
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const path = file.webkitRelativePath || file.name;
|
||||||
|
zip.file(path, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||||
|
return new File([zipBlob], `${folderName}.zip`, { type: 'application/zip' });
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to create ZIP file: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateZipFile(file: File): { valid: boolean; error?: string } {
|
||||||
|
if (file.type !== 'application/zip' && !file.name.endsWith('.zip')) {
|
||||||
|
return { valid: false, error: 'Please upload a ZIP file' };
|
||||||
|
}
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
createZipFromFiles,
|
||||||
|
validateZipFile,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user