feat: render CSVs as table (#5569)
Co-authored-by: Henrique Dias <mail@hacdias.com>
This commit is contained in:
parent
a78aaed214
commit
982405ec94
202
frontend/src/components/files/CsvViewer.vue
Normal file
202
frontend/src/components/files/CsvViewer.vue
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
<template>
|
||||||
|
<div class="csv-viewer">
|
||||||
|
<div v-if="displayError" class="csv-error">
|
||||||
|
<i class="material-icons">error</i>
|
||||||
|
<p>{{ displayError }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="data.headers.length === 0" class="csv-empty">
|
||||||
|
<i class="material-icons">description</i>
|
||||||
|
<p>{{ $t("files.lonely") }}</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="csv-table-container"
|
||||||
|
@wheel.stop
|
||||||
|
@touchmove.stop
|
||||||
|
>
|
||||||
|
<table class="csv-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th v-for="(header, index) in data.headers" :key="index">
|
||||||
|
{{ header || `Column ${index + 1}` }}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(row, rowIndex) in data.rows" :key="rowIndex">
|
||||||
|
<td v-for="(cell, cellIndex) in row" :key="cellIndex">
|
||||||
|
{{ cell }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div v-if="data.rows.length > 100" class="csv-info">
|
||||||
|
<i class="material-icons">info</i>
|
||||||
|
<span>Showing {{ data.rows.length }} rows</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { parseCSV, type CsvData } from "@/utils/csv";
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
content: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
error: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = computed<CsvData>(() => {
|
||||||
|
try {
|
||||||
|
return parseCSV(props.content);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to parse CSV:", e);
|
||||||
|
return { headers: [], rows: [] };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const displayError = computed(() => {
|
||||||
|
// External error takes priority (e.g., file too large)
|
||||||
|
if (props.error) {
|
||||||
|
return props.error;
|
||||||
|
}
|
||||||
|
// Check for parse errors
|
||||||
|
if (props.content && props.content.trim().length > 0 && data.value.headers.length === 0) {
|
||||||
|
return "Failed to parse CSV file";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.csv-viewer {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: var(--surfacePrimary);
|
||||||
|
color: var(--textSecondary);
|
||||||
|
padding: 1rem;
|
||||||
|
padding-top: 4em;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.csv-error,
|
||||||
|
.csv-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
gap: 1rem;
|
||||||
|
color: var(--textPrimary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.csv-error i,
|
||||||
|
.csv-empty i {
|
||||||
|
font-size: 4rem;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.csv-error p,
|
||||||
|
.csv-empty p {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.csv-table-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
background-color: var(--surfacePrimary);
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling for better visibility */
|
||||||
|
.csv-table-container::-webkit-scrollbar {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.csv-table-container::-webkit-scrollbar-track {
|
||||||
|
background: var(--background);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.csv-table-container::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--borderSecondary);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.csv-table-container::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--textPrimary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.csv-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
background-color: var(--surfacePrimary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.csv-table thead {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background-color: var(--surfaceSecondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.csv-table th {
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: 2px solid var(--borderSecondary);
|
||||||
|
background-color: var(--surfaceSecondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--textSecondary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.csv-table td {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--borderPrimary);
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 400px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
color: var(--textSecondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.csv-table tbody tr:nth-child(even) {
|
||||||
|
background-color: var(--background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.csv-table tbody tr:hover {
|
||||||
|
background-color: var(--hover);
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.csv-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
background-color: var(--surfaceSecondary);
|
||||||
|
border-radius: 4px;
|
||||||
|
border-left: 3px solid var(--blue);
|
||||||
|
color: var(--textSecondary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.csv-info i {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: var(--blue);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -43,7 +43,8 @@
|
|||||||
"upload": "Upload",
|
"upload": "Upload",
|
||||||
"openFile": "Open file",
|
"openFile": "Open file",
|
||||||
"discardChanges": "Discard",
|
"discardChanges": "Discard",
|
||||||
"saveChanges": "Save changes"
|
"saveChanges": "Save changes",
|
||||||
|
"editAsText": "Edit as Text"
|
||||||
},
|
},
|
||||||
"download": {
|
"download": {
|
||||||
"downloadFile": "Download File",
|
"downloadFile": "Download File",
|
||||||
@ -75,7 +76,9 @@
|
|||||||
"sortByLastModified": "Sort by last modified",
|
"sortByLastModified": "Sort by last modified",
|
||||||
"sortByName": "Sort by name",
|
"sortByName": "Sort by name",
|
||||||
"sortBySize": "Sort by size",
|
"sortBySize": "Sort by size",
|
||||||
"noPreview": "Preview is not available for this file."
|
"noPreview": "Preview is not available for this file.",
|
||||||
|
"csvTooLarge": "CSV file is too large for preview (>5MB). Please download to view.",
|
||||||
|
"csvLoadFailed": "Failed to load CSV file."
|
||||||
},
|
},
|
||||||
"help": {
|
"help": {
|
||||||
"click": "select file or directory",
|
"click": "select file or directory",
|
||||||
|
|||||||
61
frontend/src/utils/csv.ts
Normal file
61
frontend/src/utils/csv.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
export interface CsvData {
|
||||||
|
headers: string[];
|
||||||
|
rows: string[][];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse CSV content into headers and rows
|
||||||
|
* Supports quoted fields and handles commas within quotes
|
||||||
|
*/
|
||||||
|
export function parseCSV(content: string): CsvData {
|
||||||
|
if (!content || content.trim().length === 0) {
|
||||||
|
return { headers: [], rows: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = content.split(/\r?\n/);
|
||||||
|
const result: string[][] = [];
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.trim().length === 0) continue;
|
||||||
|
|
||||||
|
const row: string[] = [];
|
||||||
|
let currentField = "";
|
||||||
|
let inQuotes = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
const char = line[i];
|
||||||
|
const nextChar = line[i + 1];
|
||||||
|
|
||||||
|
if (char === '"') {
|
||||||
|
if (inQuotes && nextChar === '"') {
|
||||||
|
// Escaped quote
|
||||||
|
currentField += '"';
|
||||||
|
i++; // Skip next quote
|
||||||
|
} else {
|
||||||
|
// Toggle quote state
|
||||||
|
inQuotes = !inQuotes;
|
||||||
|
}
|
||||||
|
} else if (char === "," && !inQuotes) {
|
||||||
|
// Field separator
|
||||||
|
row.push(currentField);
|
||||||
|
currentField = "";
|
||||||
|
} else {
|
||||||
|
currentField += char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the last field
|
||||||
|
row.push(currentField);
|
||||||
|
result.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
return { headers: [], rows: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// First row is headers
|
||||||
|
const headers = result[0];
|
||||||
|
const rows = result.slice(1);
|
||||||
|
|
||||||
|
return { headers, rows };
|
||||||
|
}
|
||||||
@ -69,6 +69,12 @@ const currentView = computed(() => {
|
|||||||
|
|
||||||
if (fileStore.req.isDir) {
|
if (fileStore.req.isDir) {
|
||||||
return FileListing;
|
return FileListing;
|
||||||
|
} else if (fileStore.req.extension.toLowerCase() === ".csv") {
|
||||||
|
// CSV files use Preview for table view, unless ?edit=true
|
||||||
|
if (route.query.edit === "true") {
|
||||||
|
return Editor;
|
||||||
|
}
|
||||||
|
return Preview;
|
||||||
} else if (
|
} else if (
|
||||||
fileStore.req.type === "text" ||
|
fileStore.req.type === "text" ||
|
||||||
fileStore.req.type === "textImmutable"
|
fileStore.req.type === "textImmutable"
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
@mousemove="toggleNavigation"
|
@mousemove="toggleNavigation"
|
||||||
@touchstart="toggleNavigation"
|
@touchstart="toggleNavigation"
|
||||||
>
|
>
|
||||||
<header-bar v-if="isPdf || isEpub || showNav">
|
<header-bar v-if="isPdf || isEpub || isCsv || showNav">
|
||||||
<action icon="close" :label="$t('buttons.close')" @action="close()" />
|
<action icon="close" :label="$t('buttons.close')" @action="close()" />
|
||||||
<title>{{ name }}</title>
|
<title>{{ name }}</title>
|
||||||
<action
|
<action
|
||||||
@ -24,6 +24,13 @@
|
|||||||
:label="$t('buttons.rename')"
|
:label="$t('buttons.rename')"
|
||||||
show="rename"
|
show="rename"
|
||||||
/>
|
/>
|
||||||
|
<action
|
||||||
|
:disabled="layoutStore.loading"
|
||||||
|
v-if="isCsv && authStore.user?.perm.modify"
|
||||||
|
icon="edit_note"
|
||||||
|
:label="t('buttons.editAsText')"
|
||||||
|
@action="editAsText"
|
||||||
|
/>
|
||||||
<action
|
<action
|
||||||
:disabled="layoutStore.loading"
|
:disabled="layoutStore.loading"
|
||||||
v-if="authStore.user?.perm.delete"
|
v-if="authStore.user?.perm.delete"
|
||||||
@ -87,6 +94,7 @@
|
|||||||
<span>{{ size }}%</span>
|
<span>{{ size }}%</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<CsvViewer v-else-if="isCsv" :content="csvContent" :error="csvError" />
|
||||||
<ExtendedImage
|
<ExtendedImage
|
||||||
v-else-if="fileStore.req?.type == 'image'"
|
v-else-if="fileStore.req?.type == 'image'"
|
||||||
:src="previewUrl"
|
:src="previewUrl"
|
||||||
@ -176,11 +184,17 @@ import HeaderBar from "@/components/header/HeaderBar.vue";
|
|||||||
import Action from "@/components/header/Action.vue";
|
import Action from "@/components/header/Action.vue";
|
||||||
import ExtendedImage from "@/components/files/ExtendedImage.vue";
|
import ExtendedImage from "@/components/files/ExtendedImage.vue";
|
||||||
import VideoPlayer from "@/components/files/VideoPlayer.vue";
|
import VideoPlayer from "@/components/files/VideoPlayer.vue";
|
||||||
|
import CsvViewer from "@/components/files/CsvViewer.vue";
|
||||||
import { VueReader } from "vue-reader";
|
import { VueReader } from "vue-reader";
|
||||||
import { computed, inject, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
import { computed, inject, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import type { Rendition } from "epubjs";
|
import type { Rendition } from "epubjs";
|
||||||
import { getTheme } from "@/utils/theme";
|
import { getTheme } from "@/utils/theme";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
|
||||||
|
// CSV file size limit for preview (5MB)
|
||||||
|
// Prevents browser memory issues with large files
|
||||||
|
const CSV_MAX_SIZE = 5 * 1024 * 1024;
|
||||||
|
|
||||||
const location = useStorage("book-progress", 0, undefined, {
|
const location = useStorage("book-progress", 0, undefined, {
|
||||||
serializer: {
|
serializer: {
|
||||||
@ -239,6 +253,8 @@ const hoverNav = ref<boolean>(false);
|
|||||||
const autoPlay = ref<boolean>(false);
|
const autoPlay = ref<boolean>(false);
|
||||||
const previousRaw = ref<string>("");
|
const previousRaw = ref<string>("");
|
||||||
const nextRaw = ref<string>("");
|
const nextRaw = ref<string>("");
|
||||||
|
const csvContent = ref<string>("");
|
||||||
|
const csvError = ref<string>("");
|
||||||
|
|
||||||
const player = ref<HTMLVideoElement | HTMLAudioElement | null>(null);
|
const player = ref<HTMLVideoElement | HTMLAudioElement | null>(null);
|
||||||
|
|
||||||
@ -248,6 +264,8 @@ const authStore = useAuthStore();
|
|||||||
const fileStore = useFileStore();
|
const fileStore = useFileStore();
|
||||||
const layoutStore = useLayoutStore();
|
const layoutStore = useLayoutStore();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@ -279,6 +297,7 @@ const isPdf = computed(() => fileStore.req?.extension.toLowerCase() == ".pdf");
|
|||||||
const isEpub = computed(
|
const isEpub = computed(
|
||||||
() => fileStore.req?.extension.toLowerCase() == ".epub"
|
() => fileStore.req?.extension.toLowerCase() == ".epub"
|
||||||
);
|
);
|
||||||
|
const isCsv = computed(() => fileStore.req?.extension.toLowerCase() == ".csv");
|
||||||
|
|
||||||
const isResizeEnabled = computed(() => resizePreview);
|
const isResizeEnabled = computed(() => resizePreview);
|
||||||
|
|
||||||
@ -366,6 +385,18 @@ const updatePreview = async () => {
|
|||||||
const dirs = route.fullPath.split("/");
|
const dirs = route.fullPath.split("/");
|
||||||
name.value = decodeURIComponent(dirs[dirs.length - 1]);
|
name.value = decodeURIComponent(dirs[dirs.length - 1]);
|
||||||
|
|
||||||
|
// Load CSV content if it's a CSV file
|
||||||
|
if (isCsv.value && fileStore.req) {
|
||||||
|
csvContent.value = "";
|
||||||
|
csvError.value = "";
|
||||||
|
|
||||||
|
if (fileStore.req.size > CSV_MAX_SIZE) {
|
||||||
|
csvError.value = t("files.csvTooLarge");
|
||||||
|
} else {
|
||||||
|
csvContent.value = fileStore.req.content ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!listing.value) {
|
if (!listing.value) {
|
||||||
try {
|
try {
|
||||||
const path = url.removeLastDir(route.path);
|
const path = url.removeLastDir(route.path);
|
||||||
@ -435,4 +466,8 @@ const close = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const download = () => window.open(downloadUrl.value);
|
const download = () => window.open(downloadUrl.value);
|
||||||
|
|
||||||
|
const editAsText = () => {
|
||||||
|
router.push({ path: route.path, query: { edit: "true" } });
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user