fix(csv-viewer): add support for missing text encodings in dropdown list (#5795)
This commit is contained in:
142
frontend/src/components/DropdownModal.vue
Normal file
142
frontend/src/components/DropdownModal.vue
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, onMounted, onBeforeUnmount } from "vue";
|
||||||
|
import { vClickOutside } from "@/utils/index";
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
position?: "top-left" | "bottom-left" | "bottom-right" | "top-right";
|
||||||
|
closeOnClick?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
position: "bottom-right",
|
||||||
|
closeOnClick: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const isOpen = defineModel<boolean>();
|
||||||
|
|
||||||
|
const triggerRef = ref<HTMLElement | null>(null);
|
||||||
|
const listRef = ref<HTMLElement | null>(null);
|
||||||
|
const dropdownStyle = ref<Record<string, string>>({});
|
||||||
|
|
||||||
|
const updatePosition = () => {
|
||||||
|
if (!isOpen.value || !triggerRef.value) return;
|
||||||
|
|
||||||
|
const rect = triggerRef.value.getBoundingClientRect();
|
||||||
|
|
||||||
|
dropdownStyle.value = {
|
||||||
|
position: "fixed",
|
||||||
|
top: props.position.includes("bottom")
|
||||||
|
? `${rect.bottom + 2}px`
|
||||||
|
: `${rect.top}px`,
|
||||||
|
left: props.position.includes("left") ? `${rect.left}px` : "auto",
|
||||||
|
right: props.position.includes("right")
|
||||||
|
? `${window.innerWidth - rect.right}px`
|
||||||
|
: "auto",
|
||||||
|
zIndex: "11000",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(isOpen, (open) => {
|
||||||
|
if (open) {
|
||||||
|
updatePosition();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const onWindowChange = () => {
|
||||||
|
updatePosition();
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDropdown = (e: Event) => {
|
||||||
|
if (
|
||||||
|
e.target instanceof HTMLElement &&
|
||||||
|
listRef.value?.contains(e.target) &&
|
||||||
|
!props.closeOnClick
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isOpen.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener("resize", onWindowChange);
|
||||||
|
window.addEventListener("scroll", onWindowChange, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener("resize", onWindowChange);
|
||||||
|
window.removeEventListener("scroll", onWindowChange, true);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
export default {
|
||||||
|
directives: {
|
||||||
|
clickOutside: vClickOutside,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="dropdown-modal-container"
|
||||||
|
v-click-outside="closeDropdown"
|
||||||
|
ref="triggerRef"
|
||||||
|
>
|
||||||
|
<button @click="isOpen = !isOpen" class="dropdown-modal-trigger">
|
||||||
|
<slot></slot>
|
||||||
|
<i class="material-icons">chevron_right</i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<teleport to="body">
|
||||||
|
<div
|
||||||
|
ref="listRef"
|
||||||
|
class="dropdown-modal-list"
|
||||||
|
:class="{ 'dropdown-modal-open': isOpen }"
|
||||||
|
:style="dropdownStyle"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<slot name="list"></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dropdown-modal-trigger {
|
||||||
|
background: var(--surfacePrimary);
|
||||||
|
color: var(--textSecondary);
|
||||||
|
border: 1px solid var(--borderPrimary);
|
||||||
|
border-radius: 0.1em;
|
||||||
|
padding: 0.5em 1em;
|
||||||
|
transition: 0.2s ease all;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-modal-trigger > i {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-modal-list {
|
||||||
|
padding: 0.25rem;
|
||||||
|
background-color: var(--surfacePrimary);
|
||||||
|
color: var(--textSecondary);
|
||||||
|
display: none;
|
||||||
|
border: 1px solid var(--borderPrimary);
|
||||||
|
border-radius: 0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-modal-list > div {
|
||||||
|
max-height: 450px;
|
||||||
|
padding: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-modal-open {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -21,19 +21,37 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="header-select" v-if="isEncodedContent">
|
<div class="header-select" v-if="isEncodedContent">
|
||||||
<label for="fileEncoding">{{ $t("files.fileEncoding") }}</label>
|
<label for="fileEncoding">{{ $t("files.fileEncoding") }}</label>
|
||||||
<select
|
<DropdownModal
|
||||||
id="fileEncoding"
|
v-model="isEncondingDropdownOpen"
|
||||||
class="input input--block"
|
:close-on-click="false"
|
||||||
v-model="selectedEncoding"
|
|
||||||
>
|
>
|
||||||
<option
|
<div>
|
||||||
v-for="encoding in availableEncodings"
|
<span class="selected-encoding">{{ selectedEncoding }}</span>
|
||||||
:value="encoding"
|
</div>
|
||||||
:key="encoding"
|
<template v-slot:list>
|
||||||
>
|
<input
|
||||||
{{ encoding }}
|
v-model="encodingSearch"
|
||||||
</option>
|
:placeholder="$t('search.search')"
|
||||||
</select>
|
class="input input--block"
|
||||||
|
name="encoding"
|
||||||
|
/>
|
||||||
|
<div class="encoding-list">
|
||||||
|
<div v-if="encodingList.length == 0" class="message">
|
||||||
|
<i class="material-icons">sentiment_dissatisfied</i>
|
||||||
|
<span>{{ $t("files.lonely") }}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-for="encoding in encodingList"
|
||||||
|
:value="encoding"
|
||||||
|
:key="encoding"
|
||||||
|
class="encoding-button"
|
||||||
|
@click="selectedEncoding = encoding"
|
||||||
|
>
|
||||||
|
{{ encoding }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</DropdownModal>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="displayError" class="csv-error">
|
<div v-if="displayError" class="csv-error">
|
||||||
@@ -74,10 +92,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watchEffect } from "vue";
|
import { computed, ref, watch, watchEffect } from "vue";
|
||||||
import { parse } from "csv-parse/browser/esm";
|
import { parse } from "csv-parse/browser/esm";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import { availableEncodings, decode } from "@/utils/encodings";
|
import { availableEncodings, decode } from "@/utils/encodings";
|
||||||
|
import DropdownModal from "../DropdownModal.vue";
|
||||||
|
|
||||||
const { t } = useI18n({});
|
const { t } = useI18n({});
|
||||||
|
|
||||||
@@ -90,6 +109,16 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
error: "",
|
error: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isEncondingDropdownOpen = ref(false);
|
||||||
|
|
||||||
|
const encodingSearch = ref<string>("");
|
||||||
|
|
||||||
|
const encodingList = computed(() => {
|
||||||
|
return availableEncodings.filter((e) =>
|
||||||
|
e.toLowerCase().includes(encodingSearch.value.toLowerCase())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const columnSeparator = ref([",", ";"]);
|
const columnSeparator = ref([",", ";"]);
|
||||||
|
|
||||||
const selectedEncoding = ref("utf-8");
|
const selectedEncoding = ref("utf-8");
|
||||||
@@ -128,6 +157,11 @@ watchEffect(() => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch(selectedEncoding, () => {
|
||||||
|
isEncondingDropdownOpen.value = false;
|
||||||
|
encodingSearch.value = "";
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -271,14 +305,21 @@ watchEffect(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
|
flex-direction: column;
|
||||||
|
@media (width >= 640px) {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-select > label {
|
.header-select > label {
|
||||||
font-size: small;
|
font-size: small;
|
||||||
max-width: 80px;
|
@media (width >= 640px) {
|
||||||
|
max-width: 70px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-select > select {
|
.header-select > select,
|
||||||
|
.header-select > div {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,4 +327,40 @@ watchEffect(() => {
|
|||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
color: var(--blue);
|
color: var(--blue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.encoding-list {
|
||||||
|
max-height: 300px;
|
||||||
|
min-width: 120px;
|
||||||
|
overflow: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
touch-action: pan-y;
|
||||||
|
}
|
||||||
|
|
||||||
|
.encoding-button {
|
||||||
|
background-color: transparent;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
color: var(--textPrimary);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.encoding-button:hover {
|
||||||
|
background-color: var(--surfaceSecondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-encoding {
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
font-size: 1.25em;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
4
frontend/src/types/global.d.ts
vendored
4
frontend/src/types/global.d.ts
vendored
@@ -10,4 +10,8 @@ declare global {
|
|||||||
// TODO: no idea what the exact type is
|
// TODO: no idea what the exact type is
|
||||||
__vue__: any;
|
__vue__: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface HTMLElement {
|
||||||
|
clickOutsideEvent?: (event: Event) => void;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const availableEncodings = [
|
export const availableEncodings = [
|
||||||
|
// encodings
|
||||||
"utf-8",
|
"utf-8",
|
||||||
"ibm866",
|
"ibm866",
|
||||||
"iso-8859-2",
|
"iso-8859-2",
|
||||||
@@ -37,6 +38,178 @@ export const availableEncodings = [
|
|||||||
"euc-kr",
|
"euc-kr",
|
||||||
"utf-16be",
|
"utf-16be",
|
||||||
"utf-16le",
|
"utf-16le",
|
||||||
|
// label encodings
|
||||||
|
"unicode-1-1-utf-8",
|
||||||
|
"utf8",
|
||||||
|
"866",
|
||||||
|
"cp866",
|
||||||
|
"csibm866",
|
||||||
|
"csisolatin2",
|
||||||
|
"iso-ir-101",
|
||||||
|
"iso8859-2",
|
||||||
|
"iso88592",
|
||||||
|
"iso_8859-2",
|
||||||
|
"iso_8859-2:1987",
|
||||||
|
"l2",
|
||||||
|
"latin2",
|
||||||
|
"csisolatin3",
|
||||||
|
"iso-ir-109",
|
||||||
|
"iso8859-3",
|
||||||
|
"iso88593",
|
||||||
|
"iso_8859-3",
|
||||||
|
"iso_8859-3:1988",
|
||||||
|
"l3",
|
||||||
|
"latin3",
|
||||||
|
"csisolatin4",
|
||||||
|
"iso-ir-110",
|
||||||
|
"iso8859-4",
|
||||||
|
"iso88594",
|
||||||
|
"iso_8859-4",
|
||||||
|
"iso_8859-4:1988",
|
||||||
|
"l4",
|
||||||
|
"latin4",
|
||||||
|
"csisolatincyrillic",
|
||||||
|
"cyrillic",
|
||||||
|
"iso-ir-144",
|
||||||
|
"iso88595",
|
||||||
|
"iso_8859-5",
|
||||||
|
"iso_8859-5:1988",
|
||||||
|
"arabic",
|
||||||
|
"asmo-708",
|
||||||
|
"csiso88596e",
|
||||||
|
"csiso88596i",
|
||||||
|
"csisolatinarabic",
|
||||||
|
"ecma-114",
|
||||||
|
"iso-8859-6-e",
|
||||||
|
"iso-8859-6-i",
|
||||||
|
"iso-ir-127",
|
||||||
|
"iso8859-6",
|
||||||
|
"iso88596",
|
||||||
|
"iso_8859-6",
|
||||||
|
"iso_8859-6:1987",
|
||||||
|
"csisolatingreek",
|
||||||
|
"ecma-118",
|
||||||
|
"elot_928",
|
||||||
|
"greek",
|
||||||
|
"greek8",
|
||||||
|
"iso-ir-126",
|
||||||
|
"iso8859-7",
|
||||||
|
"iso88597",
|
||||||
|
"iso_8859-7",
|
||||||
|
"iso_8859-7:1987",
|
||||||
|
"sun_eu_greek",
|
||||||
|
"csiso88598e",
|
||||||
|
"csisolatinhebrew",
|
||||||
|
"hebrew",
|
||||||
|
"iso-8859-8-e",
|
||||||
|
"iso-ir-138",
|
||||||
|
"iso8859-8",
|
||||||
|
"iso88598",
|
||||||
|
"iso_8859-8",
|
||||||
|
"iso_8859-8:1988",
|
||||||
|
"visual",
|
||||||
|
"csiso88598i",
|
||||||
|
"logical",
|
||||||
|
"csisolatin6",
|
||||||
|
"iso-ir-157",
|
||||||
|
"iso8859-10",
|
||||||
|
"iso885910",
|
||||||
|
"l6",
|
||||||
|
"latin6",
|
||||||
|
"iso8859-13",
|
||||||
|
"iso885913",
|
||||||
|
"iso8859-14",
|
||||||
|
"iso885914",
|
||||||
|
"csisolatin9",
|
||||||
|
"iso8859-15",
|
||||||
|
"iso885915",
|
||||||
|
"l9",
|
||||||
|
"latin9",
|
||||||
|
"cskoi8r",
|
||||||
|
"koi",
|
||||||
|
"koi8",
|
||||||
|
"koi8_r",
|
||||||
|
"csmacintosh",
|
||||||
|
"mac",
|
||||||
|
"x-mac-roman",
|
||||||
|
"dos-874",
|
||||||
|
"iso-8859-11",
|
||||||
|
"iso8859-11",
|
||||||
|
"iso885911",
|
||||||
|
"tis-620",
|
||||||
|
"cp1250",
|
||||||
|
"x-cp1250",
|
||||||
|
"cp1251",
|
||||||
|
"x-cp1251",
|
||||||
|
"ansi_x3.4-1968",
|
||||||
|
"ascii",
|
||||||
|
"cp1252",
|
||||||
|
"cp819",
|
||||||
|
"csisolatin1",
|
||||||
|
"ibm819",
|
||||||
|
"iso-8859-1",
|
||||||
|
"iso-ir-100",
|
||||||
|
"iso8859-1",
|
||||||
|
"iso88591",
|
||||||
|
"iso_8859-1",
|
||||||
|
"iso_8859-1:1987",
|
||||||
|
"l1",
|
||||||
|
"latin1",
|
||||||
|
"us-ascii",
|
||||||
|
"x-cp1252",
|
||||||
|
"cp1253",
|
||||||
|
"x-cp1253",
|
||||||
|
"cp1254",
|
||||||
|
"csisolatin5",
|
||||||
|
"iso-8859-9",
|
||||||
|
"iso-ir-148",
|
||||||
|
"iso8859-9",
|
||||||
|
"iso88599",
|
||||||
|
"iso_8859-9",
|
||||||
|
"iso_8859-9:1989",
|
||||||
|
"l5",
|
||||||
|
"latin5",
|
||||||
|
"x-cp1254",
|
||||||
|
"cp1255",
|
||||||
|
"x-cp1255",
|
||||||
|
"cp1256",
|
||||||
|
"x-cp1256",
|
||||||
|
"cp1257",
|
||||||
|
"x-cp1257",
|
||||||
|
"cp1258",
|
||||||
|
"x-cp1258",
|
||||||
|
"x-mac-ukrainian",
|
||||||
|
"chinese",
|
||||||
|
"csgb2312",
|
||||||
|
"csiso58gb231280",
|
||||||
|
"gb2312",
|
||||||
|
"gb_2312",
|
||||||
|
"gb_2312-80",
|
||||||
|
"iso-ir-58",
|
||||||
|
"x-gbk",
|
||||||
|
"big5-hkscs",
|
||||||
|
"cn-big5",
|
||||||
|
"csbig5",
|
||||||
|
"x-x-big5",
|
||||||
|
"cseucpkdfmtjapanese",
|
||||||
|
"x-euc-jp",
|
||||||
|
"csiso2022jp",
|
||||||
|
"csshiftjis",
|
||||||
|
"ms_kanji",
|
||||||
|
"shift-jis",
|
||||||
|
"sjis",
|
||||||
|
"windows-31j",
|
||||||
|
"x-sjis",
|
||||||
|
"cseuckr",
|
||||||
|
"csksc56011987",
|
||||||
|
"iso-ir-149",
|
||||||
|
"korean",
|
||||||
|
"ks_c_5601-1987",
|
||||||
|
"ks_c_5601-1989",
|
||||||
|
"ksc5601",
|
||||||
|
"ksc_5601",
|
||||||
|
"windows-949",
|
||||||
|
"utf-16",
|
||||||
];
|
];
|
||||||
|
|
||||||
export function decode(content: ArrayBuffer, encoding: string): string {
|
export function decode(content: ArrayBuffer, encoding: string): string {
|
||||||
|
|||||||
@@ -4,3 +4,25 @@ import { partial } from "filesize";
|
|||||||
* Formats filesize as KiB/MiB/...
|
* Formats filesize as KiB/MiB/...
|
||||||
*/
|
*/
|
||||||
export const filesize = partial({ base: 2 });
|
export const filesize = partial({ base: 2 });
|
||||||
|
|
||||||
|
export const vClickOutside = {
|
||||||
|
created(el: HTMLElement, binding: any) {
|
||||||
|
el.clickOutsideEvent = (event: Event) => {
|
||||||
|
const target = event.target;
|
||||||
|
|
||||||
|
if (target instanceof Node) {
|
||||||
|
if (!el.contains(target)) {
|
||||||
|
binding.value(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("click", el.clickOutsideEvent);
|
||||||
|
},
|
||||||
|
|
||||||
|
unmounted(el: HTMLElement) {
|
||||||
|
if (el.clickOutsideEvent) {
|
||||||
|
document.removeEventListener("click", el.clickOutsideEvent);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -311,7 +311,11 @@ 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 isCsv = computed(
|
||||||
|
() =>
|
||||||
|
fileStore.req?.extension.toLowerCase() == ".csv" &&
|
||||||
|
fileStore.req.size <= CSV_MAX_SIZE
|
||||||
|
);
|
||||||
|
|
||||||
const isResizeEnabled = computed(() => resizePreview);
|
const isResizeEnabled = computed(() => resizePreview);
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ var resourceGetHandler = withUser(func(w http.ResponseWriter, r *http.Request, d
|
|||||||
return renderJSON(w, r, file)
|
return renderJSON(w, r, file)
|
||||||
} else if encoding == "true" {
|
} else if encoding == "true" {
|
||||||
if file.Type != "text" {
|
if file.Type != "text" {
|
||||||
return http.StatusUnsupportedMediaType, fmt.Errorf("file is not a text file")
|
return renderJSON(w, r, file)
|
||||||
}
|
}
|
||||||
|
|
||||||
f, err := d.user.Fs.Open(r.URL.Path)
|
f, err := d.user.Fs.Open(r.URL.Path)
|
||||||
|
|||||||
Reference in New Issue
Block a user