- Normalize line endings in Markdown export for DOCX files. - Improve selection serialization to Markdown with better handling of empty documents. - Add a new `updateFile` function to the file system for updating file properties. - Introduce video transcoding capabilities using FFmpeg, supporting various video formats. - Update AGENTS.md for clearer plugin structure and responsibilities. - Add scoped styles for TreeNodeItem component to improve UI consistency. - Implement cross-origin isolation headers in Vite configuration for enhanced security. - Remove obsolete test_cross.py file.
1401 lines
39 KiB
Vue
1401 lines
39 KiB
Vue
<script setup>
|
||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||
import MarkdownIt from 'markdown-it'
|
||
import { isOfficeFile, getOfficeFormat } from '../services/officeDetection'
|
||
import { canPlayVideoNatively, editVideoBlob, isVideoFile, transcodeVideoToMp4 } from '../utils/ffmpeg'
|
||
import ImageEditorComponent from './ImageEditorComponent.vue'
|
||
import UniverPreview from './UniverPreview.vue'
|
||
|
||
const props = defineProps({
|
||
node: { type: Object, default: null },
|
||
breadcrumb: { type: Array, default: () => [] },
|
||
rootNodes: { type: Array, default: () => [] },
|
||
getFileIcon: { type: Function, default: () => 'file' },
|
||
getFileBlob: { type: Function, default: () => null },
|
||
updateFile: { type: Function, default: null },
|
||
showSidebarToggle: { type: Boolean, default: false }
|
||
})
|
||
|
||
const emit = defineEmits(['navigate', 'toggle-sidebar'])
|
||
|
||
const md = new MarkdownIt({
|
||
html: false,
|
||
breaks: true,
|
||
linkify: true
|
||
})
|
||
|
||
const basePreviewUrl = ref('')
|
||
const editedVideoUrl = ref('')
|
||
const editedVideoBlob = ref(null)
|
||
const imageEditorRef = ref(null)
|
||
const imageEditorError = ref('')
|
||
const isEditingImage = ref(false)
|
||
const isSavingImage = ref(false)
|
||
const isTranscodingVideo = ref(false)
|
||
const transcodeProgress = ref(0)
|
||
const videoPreviewError = ref('')
|
||
const isUsingTranscodedVideo = ref(false)
|
||
const isApplyingVideoEdit = ref(false)
|
||
const videoEditProgress = ref(0)
|
||
const videoEditError = ref('')
|
||
const videoElement = ref(null)
|
||
const videoDurationSeconds = ref(0)
|
||
const videoCurrentTime = ref(0)
|
||
const clipStartSeconds = ref(0)
|
||
const clipEndSeconds = ref(0)
|
||
const muteVideoAudio = ref(false)
|
||
let previewTaskId = 0
|
||
|
||
const isRoot = computed(() => !props.node)
|
||
const isFolder = computed(() => props.node?.type === 'folder')
|
||
const fileExt = computed(() => {
|
||
if (!props.node || props.node.type !== 'file') return ''
|
||
const parts = props.node.name.split('.')
|
||
return parts.length > 1 ? parts.pop().toLowerCase() : ''
|
||
})
|
||
const isMarkdown = computed(() => ['md', 'markdown'].includes(fileExt.value))
|
||
const isImage = computed(() => {
|
||
const mime = String(props.node?.mimeType || '')
|
||
return mime.startsWith('image/') || ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(fileExt.value)
|
||
})
|
||
const isPdf = computed(() => {
|
||
const mime = String(props.node?.mimeType || '')
|
||
return mime === 'application/pdf' || fileExt.value === 'pdf'
|
||
})
|
||
const isVideo = computed(() => isVideoFile(props.node))
|
||
const isText = computed(() => {
|
||
const mime = String(props.node?.mimeType || '')
|
||
return Boolean(props.node?.content || props.node?.previewText) || mime.startsWith('text/') || mime.includes('json') || ['txt', 'json', 'js', 'jsx', 'ts', 'tsx', 'css', 'html', 'htm', 'py', 'vue', 'xml', 'yaml', 'yml', 'csv', 'log', 'sql', 'toml', 'ini', 'cfg', 'conf', 'sh', 'bat', 'ps1', 'java', 'c', 'cpp', 'h', 'hpp', 'go', 'rs'].includes(fileExt.value)
|
||
})
|
||
const isOffice = computed(() => {
|
||
if (!props.node || props.node.type !== 'file') return false
|
||
if (!props.node.name) return false
|
||
return isOfficeFile({ name: props.node.name, type: props.node.mimeType })
|
||
})
|
||
const officeFormat = computed(() => {
|
||
if (!isOffice.value) return null
|
||
if (!props.node?.name) return null
|
||
return getOfficeFormat({ name: props.node.name, type: props.node.mimeType })
|
||
})
|
||
const folderItems = computed(() => {
|
||
if (!isRoot.value && !isFolder.value) return []
|
||
return isRoot.value ? props.rootNodes : props.node.children || []
|
||
})
|
||
const fileBlob = computed(() => props.getFileBlob(props.node))
|
||
const previewText = computed(() => props.node?.content || props.node?.previewText || '')
|
||
const lineCount = computed(() => {
|
||
if (!previewText.value) return 0
|
||
return previewText.value.split('\n').length
|
||
})
|
||
const renderedMarkdown = computed(() => md.render(previewText.value || ''))
|
||
const fileSizeLabel = computed(() => formatBytes(props.node?.size || fileBlob.value?.size || 0))
|
||
const locLabel = computed(() => {
|
||
const count = lineCount.value
|
||
return count ? `${count} 行` : '二进制文件'
|
||
})
|
||
const objectUrl = computed(() => editedVideoUrl.value || basePreviewUrl.value)
|
||
const canEditImage = computed(() => isImage.value && typeof props.updateFile === 'function')
|
||
const hasEditedVideo = computed(() => editedVideoBlob.value instanceof Blob)
|
||
const normalizedClipStart = computed(() => clampNumber(clipStartSeconds.value, 0, videoDurationSeconds.value || 0))
|
||
const normalizedClipEnd = computed(() => resolveClipEnd(clipEndSeconds.value, normalizedClipStart.value, videoDurationSeconds.value || 0))
|
||
const selectedClipDuration = computed(() => Math.max(0, normalizedClipEnd.value - normalizedClipStart.value))
|
||
const hasPendingVideoEdit = computed(() => {
|
||
if (!isVideo.value || !videoDurationSeconds.value) return false
|
||
const duration = videoDurationSeconds.value
|
||
return normalizedClipStart.value > 0.05 || Math.abs(normalizedClipEnd.value - duration) > 0.05 || muteVideoAudio.value
|
||
})
|
||
|
||
function clampNumber(value, min, max) {
|
||
const numeric = Number(value)
|
||
if (!Number.isFinite(numeric)) return min
|
||
return Math.min(Math.max(numeric, min), max)
|
||
}
|
||
|
||
function roundSeconds(value = 0) {
|
||
return Math.round((Number(value) || 0) * 10) / 10
|
||
}
|
||
|
||
function resolveClipEnd(value, start, duration) {
|
||
if (!duration) return 0
|
||
const numeric = Number(value)
|
||
if (!Number.isFinite(numeric) || numeric <= 0) return duration
|
||
return clampNumber(numeric, start, duration)
|
||
}
|
||
|
||
function stripExtension(name = '') {
|
||
return String(name).replace(/\.[^.]+$/, '')
|
||
}
|
||
|
||
function buildEditedVideoName(name = 'video.mp4') {
|
||
return `${stripExtension(name)}-edited.mp4`
|
||
}
|
||
|
||
function revokeUrl(url = '') {
|
||
if (!url) return
|
||
URL.revokeObjectURL(url)
|
||
}
|
||
|
||
function clearEditedVideoResult() {
|
||
revokeUrl(editedVideoUrl.value)
|
||
editedVideoUrl.value = ''
|
||
editedVideoBlob.value = null
|
||
}
|
||
|
||
function clearBasePreview() {
|
||
revokeUrl(basePreviewUrl.value)
|
||
basePreviewUrl.value = ''
|
||
}
|
||
|
||
function resetVideoPreviewState() {
|
||
isTranscodingVideo.value = false
|
||
transcodeProgress.value = 0
|
||
videoPreviewError.value = ''
|
||
isUsingTranscodedVideo.value = false
|
||
}
|
||
|
||
function resetVideoEditState() {
|
||
isApplyingVideoEdit.value = false
|
||
videoEditProgress.value = 0
|
||
videoEditError.value = ''
|
||
videoDurationSeconds.value = 0
|
||
videoCurrentTime.value = 0
|
||
clipStartSeconds.value = 0
|
||
clipEndSeconds.value = 0
|
||
muteVideoAudio.value = false
|
||
clearEditedVideoResult()
|
||
}
|
||
|
||
function normalizeVideoEditRange() {
|
||
if (!videoDurationSeconds.value) {
|
||
clipStartSeconds.value = 0
|
||
clipEndSeconds.value = 0
|
||
return
|
||
}
|
||
|
||
clipStartSeconds.value = roundSeconds(normalizedClipStart.value)
|
||
clipEndSeconds.value = roundSeconds(normalizedClipEnd.value)
|
||
}
|
||
|
||
function assignBasePreviewUrl(url = '') {
|
||
clearBasePreview()
|
||
basePreviewUrl.value = url
|
||
}
|
||
|
||
function setVideoRangeFromDuration(duration = 0) {
|
||
videoDurationSeconds.value = duration
|
||
videoCurrentTime.value = 0
|
||
clipStartSeconds.value = 0
|
||
clipEndSeconds.value = roundSeconds(duration)
|
||
}
|
||
|
||
watch(
|
||
[fileBlob, () => props.node?.id],
|
||
async ([blob]) => {
|
||
const currentTaskId = ++previewTaskId
|
||
clearBasePreview()
|
||
resetVideoPreviewState()
|
||
resetVideoEditState()
|
||
|
||
if (!(blob instanceof Blob)) return
|
||
|
||
if (!isVideo.value) {
|
||
assignBasePreviewUrl(URL.createObjectURL(blob))
|
||
return
|
||
}
|
||
|
||
if (canPlayVideoNatively(props.node)) {
|
||
assignBasePreviewUrl(URL.createObjectURL(blob))
|
||
return
|
||
}
|
||
|
||
isTranscodingVideo.value = true
|
||
try {
|
||
const transcodedBlob = await transcodeVideoToMp4(blob, props.node?.name || 'video', {
|
||
onProgress(progress) {
|
||
if (currentTaskId !== previewTaskId) return
|
||
transcodeProgress.value = Math.max(transcodeProgress.value, Math.round(progress * 100))
|
||
}
|
||
})
|
||
|
||
if (currentTaskId !== previewTaskId) return
|
||
|
||
assignBasePreviewUrl(URL.createObjectURL(transcodedBlob))
|
||
isUsingTranscodedVideo.value = true
|
||
} catch (error) {
|
||
if (currentTaskId !== previewTaskId) return
|
||
videoPreviewError.value = error instanceof Error && error.message
|
||
? error.message
|
||
: '当前视频暂时无法在浏览器内转换预览'
|
||
} finally {
|
||
if (currentTaskId === previewTaskId) {
|
||
isTranscodingVideo.value = false
|
||
}
|
||
}
|
||
},
|
||
{ immediate: true }
|
||
)
|
||
|
||
watch(
|
||
() => props.node?.id,
|
||
() => {
|
||
isEditingImage.value = false
|
||
isSavingImage.value = false
|
||
imageEditorError.value = ''
|
||
}
|
||
)
|
||
|
||
onBeforeUnmount(() => {
|
||
previewTaskId += 1
|
||
clearBasePreview()
|
||
clearEditedVideoResult()
|
||
})
|
||
|
||
function formatBytes(bytes = 0) {
|
||
if (!bytes) return '0 B'
|
||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||
let value = bytes
|
||
let index = 0
|
||
while (value >= 1024 && index < units.length - 1) {
|
||
value /= 1024
|
||
index += 1
|
||
}
|
||
return `${value >= 100 || index === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[index]}`
|
||
}
|
||
|
||
function formatDate(timestamp) {
|
||
if (!timestamp) return '-'
|
||
return new Date(timestamp).toLocaleString('zh-CN')
|
||
}
|
||
|
||
function navigateTo(id) {
|
||
emit('navigate', id)
|
||
}
|
||
|
||
function toggleSidebar() {
|
||
emit('toggle-sidebar')
|
||
}
|
||
|
||
function getImageExportFormat() {
|
||
if (fileExt.value === 'jpg' || fileExt.value === 'jpeg') return 'jpeg'
|
||
if (fileExt.value === 'webp') return 'webp'
|
||
return 'png'
|
||
}
|
||
|
||
function startImageEditing() {
|
||
if (!canEditImage.value || !objectUrl.value || isSavingImage.value) return
|
||
imageEditorError.value = ''
|
||
isEditingImage.value = true
|
||
}
|
||
|
||
function cancelImageEditing() {
|
||
if (isSavingImage.value) return
|
||
imageEditorError.value = ''
|
||
isEditingImage.value = false
|
||
}
|
||
|
||
function handleImageEditorError(message) {
|
||
imageEditorError.value = String(message || '图片编辑器加载失败')
|
||
}
|
||
|
||
async function saveImageEdits() {
|
||
if (!props.node?.id || typeof props.updateFile !== 'function' || !imageEditorRef.value) return
|
||
|
||
isSavingImage.value = true
|
||
imageEditorError.value = ''
|
||
|
||
try {
|
||
const editedBlob = await imageEditorRef.value.exportImageBlob({
|
||
format: getImageExportFormat(),
|
||
quality: 0.92
|
||
})
|
||
|
||
const updated = await props.updateFile(props.node.id, editedBlob, {
|
||
mimeType: editedBlob.type || props.node?.mimeType || '',
|
||
storageKind: 'blob',
|
||
previewText: '',
|
||
isTruncatedPreview: false
|
||
})
|
||
|
||
if (updated === false) {
|
||
throw new Error('保存图片失败')
|
||
}
|
||
|
||
isEditingImage.value = false
|
||
} catch (error) {
|
||
imageEditorError.value = error instanceof Error && error.message
|
||
? error.message
|
||
: '保存图片失败'
|
||
} finally {
|
||
isSavingImage.value = false
|
||
}
|
||
}
|
||
|
||
function formatDuration(seconds = 0) {
|
||
const total = Math.max(0, Number(seconds) || 0)
|
||
const hours = Math.floor(total / 3600)
|
||
const minutes = Math.floor((total % 3600) / 60)
|
||
const secs = total % 60
|
||
const paddedSeconds = secs >= 10 ? secs.toFixed(1).padStart(4, '0') : secs.toFixed(1).padStart(3, '0')
|
||
|
||
if (hours > 0) {
|
||
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${paddedSeconds.padStart(4, '0')}`
|
||
}
|
||
|
||
return `${String(minutes).padStart(2, '0')}:${paddedSeconds.padStart(4, '0')}`
|
||
}
|
||
|
||
function downloadBlob(blob, fileName) {
|
||
if (!(blob instanceof Blob)) return
|
||
const url = URL.createObjectURL(blob)
|
||
const anchor = document.createElement('a')
|
||
anchor.href = url
|
||
anchor.download = fileName
|
||
document.body.appendChild(anchor)
|
||
anchor.click()
|
||
anchor.remove()
|
||
setTimeout(() => URL.revokeObjectURL(url), 0)
|
||
}
|
||
|
||
function handleVideoLoadedMetadata(event) {
|
||
const duration = Number(event.target?.duration || 0)
|
||
if (!Number.isFinite(duration)) return
|
||
setVideoRangeFromDuration(duration)
|
||
}
|
||
|
||
function handleVideoTimeUpdate(event) {
|
||
const currentTime = Number(event.target?.currentTime || 0)
|
||
if (!Number.isFinite(currentTime)) return
|
||
videoCurrentTime.value = currentTime
|
||
}
|
||
|
||
function setClipStartFromCurrentTime() {
|
||
if (!videoElement.value) return
|
||
clipStartSeconds.value = roundSeconds(videoElement.value.currentTime || 0)
|
||
if (clipEndSeconds.value < clipStartSeconds.value) {
|
||
clipEndSeconds.value = roundSeconds(videoDurationSeconds.value || clipStartSeconds.value)
|
||
}
|
||
normalizeVideoEditRange()
|
||
}
|
||
|
||
function setClipEndFromCurrentTime() {
|
||
if (!videoElement.value) return
|
||
clipEndSeconds.value = roundSeconds(videoElement.value.currentTime || 0)
|
||
if (clipEndSeconds.value < clipStartSeconds.value) {
|
||
clipStartSeconds.value = roundSeconds(Math.max(0, clipEndSeconds.value - 0.1))
|
||
}
|
||
normalizeVideoEditRange()
|
||
}
|
||
|
||
async function applyVideoEdits() {
|
||
if (!isVideo.value) return
|
||
normalizeVideoEditRange()
|
||
|
||
const sourceBlob = hasEditedVideo.value ? editedVideoBlob.value : fileBlob.value
|
||
if (!(sourceBlob instanceof Blob) || !videoDurationSeconds.value || !hasPendingVideoEdit.value) return
|
||
|
||
isApplyingVideoEdit.value = true
|
||
videoEditProgress.value = 0
|
||
videoEditError.value = ''
|
||
|
||
try {
|
||
const editedBlob = await editVideoBlob(sourceBlob, hasEditedVideo.value ? buildEditedVideoName(props.node?.name || 'video.mp4') : props.node?.name || 'video.mp4', {
|
||
startTime: normalizedClipStart.value,
|
||
endTime: normalizedClipEnd.value,
|
||
muteAudio: muteVideoAudio.value,
|
||
onProgress(progress) {
|
||
videoEditProgress.value = Math.max(videoEditProgress.value, Math.round(progress * 100))
|
||
}
|
||
})
|
||
|
||
clearEditedVideoResult()
|
||
editedVideoBlob.value = editedBlob
|
||
editedVideoUrl.value = URL.createObjectURL(editedBlob)
|
||
} catch (error) {
|
||
videoEditError.value = error instanceof Error && error.message
|
||
? error.message
|
||
: '视频编辑失败'
|
||
} finally {
|
||
isApplyingVideoEdit.value = false
|
||
}
|
||
}
|
||
|
||
function restoreOriginalVideo() {
|
||
clearEditedVideoResult()
|
||
videoEditError.value = ''
|
||
videoEditProgress.value = 0
|
||
muteVideoAudio.value = false
|
||
}
|
||
|
||
function downloadEditedVideo() {
|
||
if (!(editedVideoBlob.value instanceof Blob)) return
|
||
downloadBlob(editedVideoBlob.value, buildEditedVideoName(props.node?.name || 'video.mp4'))
|
||
}
|
||
|
||
async function copyText() {
|
||
if (!previewText.value) return
|
||
try {
|
||
await navigator.clipboard.writeText(previewText.value)
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
function openRaw() {
|
||
if (!objectUrl.value) return
|
||
window.open(objectUrl.value, '_blank', 'noopener,noreferrer')
|
||
}
|
||
|
||
function downloadFile() {
|
||
const blob = fileBlob.value
|
||
if (!blob) return
|
||
downloadBlob(blob, props.node?.name || 'download')
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="file-content">
|
||
<div v-if="isRoot || isFolder" class="directory-shell">
|
||
<div class="directory-card">
|
||
<div class="directory-card-header">
|
||
<div>
|
||
<h3>{{ isRoot ? '本地文件空间' : node.name }}</h3>
|
||
<p>{{ isRoot ? '所有文件都保存在当前浏览器本地,不会上传到服务器。' : '像 GitHub 一样浏览当前目录内容。' }}</p>
|
||
</div>
|
||
<div class="directory-header-meta">
|
||
<span>{{ folderItems.length }} 项</span>
|
||
<span>{{ isRoot ? '根目录' : '目录' }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="directory-table">
|
||
<div class="directory-row directory-row-head">
|
||
<span class="col-name">Name</span>
|
||
<span class="col-size">Size</span>
|
||
<span class="col-date">Updated</span>
|
||
</div>
|
||
|
||
<div v-if="!isRoot" class="directory-row directory-parent" @click="navigateTo(node.parentId || null)">
|
||
<span class="col-name">..</span>
|
||
<span class="col-size">-</span>
|
||
<span class="col-date">返回上级</span>
|
||
</div>
|
||
|
||
<button
|
||
v-for="item in folderItems"
|
||
:key="item.id"
|
||
class="directory-row directory-button"
|
||
type="button"
|
||
@click="navigateTo(item.id)"
|
||
>
|
||
<span class="col-name">
|
||
<span :class="item.type === 'folder' ? 'icon-folder' : ['icon-file', `icon-${getFileIcon(item.name)}`]"></span>
|
||
<span class="name-text">{{ item.name }}</span>
|
||
</span>
|
||
<span class="col-size">{{ item.type === 'folder' ? '-' : formatBytes(item.size) }}</span>
|
||
<span class="col-date">{{ formatDate(item.updatedAt) }}</span>
|
||
</button>
|
||
|
||
<div v-if="folderItems.length === 0" class="directory-empty">
|
||
这里还没有文件。你可以从左侧直接上传文件,或者新建一个目录开始整理。
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<template v-else-if="node">
|
||
<div class="github-file-header">
|
||
<div class="file-header-row">
|
||
<button
|
||
v-if="showSidebarToggle"
|
||
class="sidebar-toggle-btn"
|
||
type="button"
|
||
title="切换左侧栏"
|
||
@click="toggleSidebar"
|
||
>
|
||
<svg viewBox="0 0 16 16" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M2.75 2A1.75 1.75 0 001 3.75v8.5C1 13.216 1.784 14 2.75 14h10.5A1.75 1.75 0 0015 12.25v-8.5A1.75 1.75 0 0013.25 2H2.75zm0 1.5h2.5v9h-2.5a.25.25 0 01-.25-.25v-8.5a.25.25 0 01.25-.25zm4 9v-9h6.5a.25.25 0 01.25.25v8.5a.25.25 0 01-.25.25h-6.5z"/></svg>
|
||
</button>
|
||
|
||
<div class="file-header-main">
|
||
<div class="file-path">
|
||
<button type="button" class="breadcrumb-link" @click="navigateTo(null)">workspace</button>
|
||
<template v-for="(item, index) in breadcrumb" :key="item.id || item.name">
|
||
<span class="path-sep">/</span>
|
||
<button
|
||
v-if="index < breadcrumb.length - 1"
|
||
type="button"
|
||
class="breadcrumb-link"
|
||
@click="navigateTo(item.id)"
|
||
>
|
||
{{ item.name }}
|
||
</button>
|
||
<span v-else class="breadcrumb-current">{{ item.name }}</span>
|
||
</template>
|
||
</div>
|
||
|
||
<div class="file-header-meta">
|
||
<span class="file-meta">{{ fileSizeLabel }}</span>
|
||
<span class="file-meta">{{ locLabel }}</span>
|
||
<span v-if="node.isTruncatedPreview" class="truncated-pill">仅预览前 2MB</span>
|
||
<span v-else-if="isTranscodingVideo" class="truncated-pill">视频转换中 {{ transcodeProgress }}%</span>
|
||
<span v-else-if="isApplyingVideoEdit" class="truncated-pill">编辑处理中 {{ videoEditProgress }}%</span>
|
||
<span v-else-if="isUsingTranscodedVideo" class="truncated-pill">已转为 MP4 预览</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="file-actions">
|
||
<button
|
||
v-if="canEditImage && !isEditingImage"
|
||
class="action-btn"
|
||
type="button"
|
||
@click="startImageEditing"
|
||
:disabled="!objectUrl"
|
||
>
|
||
编辑图片
|
||
</button>
|
||
<template v-else-if="canEditImage && isEditingImage">
|
||
<button class="action-btn" type="button" @click="cancelImageEditing" :disabled="isSavingImage">取消</button>
|
||
<button class="action-btn primary-btn" type="button" @click="saveImageEdits" :disabled="isSavingImage">{{ isSavingImage ? '保存中' : '保存编辑' }}</button>
|
||
</template>
|
||
<button class="action-btn" type="button" @click="copyText" :disabled="!previewText">复制</button>
|
||
<button class="action-btn" type="button" @click="downloadFile" :disabled="!fileBlob">下载</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="isMarkdown" class="content-markdown">
|
||
<article class="markdown-body" v-html="renderedMarkdown"></article>
|
||
</div>
|
||
|
||
<div v-else-if="isText" class="content-text">
|
||
<div class="code-container">
|
||
<div class="line-numbers">
|
||
<div v-for="n in Math.max(lineCount, 1)" :key="n" class="line-number">{{ n }}</div>
|
||
</div>
|
||
<pre class="text-content">{{ previewText }}</pre>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else-if="isImage && objectUrl" class="content-preview">
|
||
<div class="preview-surface image-surface" :class="{ 'image-editor-active': isEditingImage }">
|
||
<div v-if="imageEditorError" class="image-inline-error">{{ imageEditorError }}</div>
|
||
<ImageEditorComponent
|
||
v-if="isEditingImage"
|
||
ref="imageEditorRef"
|
||
:image-url="objectUrl"
|
||
:image-name="node.name"
|
||
@error="handleImageEditorError"
|
||
/>
|
||
<img :src="objectUrl" :alt="node.name" />
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else-if="isVideo" class="content-preview">
|
||
<div class="preview-surface video-surface">
|
||
<div v-if="isTranscodingVideo" class="video-state-card">
|
||
<h3>正在准备视频预览</h3>
|
||
<p>当前格式需要先转换为浏览器兼容的 MP4,转换完成后会自动开始播放。</p>
|
||
<div class="video-progress-track" role="progressbar" :aria-valuenow="transcodeProgress" aria-valuemin="0" aria-valuemax="100">
|
||
<span class="video-progress-bar" :style="{ width: `${transcodeProgress}%` }"></span>
|
||
</div>
|
||
<strong>{{ transcodeProgress }}%</strong>
|
||
</div>
|
||
|
||
<div v-else-if="videoPreviewError" class="video-state-card video-state-error">
|
||
<h3>视频预览暂时不可用</h3>
|
||
<p>{{ videoPreviewError }}</p>
|
||
<p>原始文件仍保存在浏览器本地,你可以继续下载后使用本地播放器打开。</p>
|
||
</div>
|
||
|
||
<video
|
||
v-else-if="objectUrl"
|
||
ref="videoElement"
|
||
class="video-player"
|
||
controls
|
||
playsinline
|
||
:src="objectUrl"
|
||
@loadedmetadata="handleVideoLoadedMetadata"
|
||
@timeupdate="handleVideoTimeUpdate"
|
||
></video>
|
||
|
||
<section v-if="objectUrl" class="video-edit-panel">
|
||
<div class="video-edit-header">
|
||
<div>
|
||
<h3>基础编辑</h3>
|
||
<p>裁剪片段、静音导出,并把结果另存为新的 MP4 文件。</p>
|
||
</div>
|
||
|
||
<div class="video-edit-meta">
|
||
<span>总时长 {{ formatDuration(videoDurationSeconds) }}</span>
|
||
<span>当前播放 {{ formatDuration(videoCurrentTime) }}</span>
|
||
<span>片段时长 {{ formatDuration(selectedClipDuration) }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="video-edit-grid">
|
||
<label class="video-field">
|
||
<span>开始时间(秒)</span>
|
||
<input v-model.number="clipStartSeconds" type="number" min="0" :max="videoDurationSeconds || 0" step="0.1" @blur="normalizeVideoEditRange" />
|
||
</label>
|
||
|
||
<label class="video-field">
|
||
<span>结束时间(秒)</span>
|
||
<input v-model.number="clipEndSeconds" type="number" min="0" :max="videoDurationSeconds || 0" step="0.1" @blur="normalizeVideoEditRange" />
|
||
</label>
|
||
</div>
|
||
|
||
<div class="video-quick-actions">
|
||
<button class="ghost-btn" type="button" @click="setClipStartFromCurrentTime" :disabled="!videoDurationSeconds">用当前时间设为开始</button>
|
||
<button class="ghost-btn" type="button" @click="setClipEndFromCurrentTime" :disabled="!videoDurationSeconds">用当前时间设为结束</button>
|
||
</div>
|
||
|
||
<label class="video-checkbox">
|
||
<input v-model="muteVideoAudio" type="checkbox" />
|
||
<span>静音导出(移除音轨)</span>
|
||
</label>
|
||
|
||
<div v-if="videoEditError" class="video-inline-error">{{ videoEditError }}</div>
|
||
|
||
<div v-if="isApplyingVideoEdit" class="video-edit-progress-shell">
|
||
<div class="video-progress-track" role="progressbar" :aria-valuenow="videoEditProgress" aria-valuemin="0" aria-valuemax="100">
|
||
<span class="video-progress-bar" :style="{ width: `${videoEditProgress}%` }"></span>
|
||
</div>
|
||
<strong>{{ videoEditProgress }}%</strong>
|
||
</div>
|
||
|
||
<div class="video-edit-actions">
|
||
<button class="action-btn primary-btn" type="button" @click="applyVideoEdits" :disabled="isApplyingVideoEdit || !hasPendingVideoEdit">生成编辑预览</button>
|
||
<button class="action-btn" type="button" @click="downloadEditedVideo" :disabled="!hasEditedVideo">下载编辑结果</button>
|
||
<button class="action-btn" type="button" @click="restoreOriginalVideo" :disabled="!hasEditedVideo">恢复原片</button>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else-if="isPdf && objectUrl" class="content-preview">
|
||
<iframe class="pdf-frame" :src="objectUrl" :title="node.name"></iframe>
|
||
</div>
|
||
|
||
<div v-else-if="isOffice && fileBlob && officeFormat" class="content-preview">
|
||
<UniverPreview
|
||
:fileBlob="fileBlob"
|
||
:fileName="node.name"
|
||
:format="officeFormat"
|
||
/>
|
||
</div>
|
||
|
||
<div v-else class="content-unsupported">
|
||
<div class="unsupported-card">
|
||
<h3>暂不支持在线预览此文件</h3>
|
||
<p>文件已保存在浏览器本地,你仍然可以点击右上角“下载”获取原文件。</p>
|
||
<div class="unsupported-meta">
|
||
<span>{{ node.name }}</span>
|
||
<span>{{ fileSizeLabel }}</span>
|
||
<span>{{ node.mimeType || '未知类型' }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.file-content {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
background: #f6f8fa;
|
||
}
|
||
|
||
[data-theme='dark'] .file-content {
|
||
background: #0d1117;
|
||
}
|
||
|
||
.directory-shell,
|
||
.content-markdown,
|
||
.content-text,
|
||
.content-preview,
|
||
.content-unsupported {
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow: auto;
|
||
}
|
||
|
||
.directory-shell {
|
||
padding: 24px;
|
||
}
|
||
|
||
.directory-card {
|
||
overflow: hidden;
|
||
border: 1px solid var(--github-border);
|
||
border-radius: 12px;
|
||
background: var(--github-bg);
|
||
box-shadow: 0 20px 40px rgba(15, 23, 42, 0.06);
|
||
}
|
||
|
||
.directory-card-header {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
padding: 20px 24px;
|
||
border-bottom: 1px solid var(--github-border);
|
||
}
|
||
|
||
.directory-card-header h3 {
|
||
margin: 0;
|
||
font-size: 1.25rem;
|
||
}
|
||
|
||
.directory-card-header p {
|
||
margin: 6px 0 0;
|
||
color: var(--github-text-secondary);
|
||
font-size: 14px;
|
||
}
|
||
|
||
.directory-header-meta {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.directory-header-meta span,
|
||
.truncated-pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
height: 26px;
|
||
padding: 0 10px;
|
||
border: 1px solid var(--github-border);
|
||
border-radius: 999px;
|
||
background: var(--github-hover);
|
||
color: var(--github-text-secondary);
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.directory-table {
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.directory-row {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) 120px 180px;
|
||
align-items: center;
|
||
gap: 16px;
|
||
min-height: 48px;
|
||
padding: 0 20px;
|
||
border-bottom: 1px solid var(--github-border);
|
||
}
|
||
|
||
.directory-row-head {
|
||
min-height: 40px;
|
||
background: var(--github-hover);
|
||
color: var(--github-text-secondary);
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.04em;
|
||
}
|
||
|
||
.directory-button {
|
||
width: 100%;
|
||
border: none;
|
||
background: transparent;
|
||
color: inherit;
|
||
cursor: pointer;
|
||
text-align: left;
|
||
}
|
||
|
||
.directory-button:hover,
|
||
.directory-parent:hover {
|
||
background: var(--github-hover);
|
||
}
|
||
|
||
.directory-parent {
|
||
cursor: pointer;
|
||
color: var(--github-text-secondary);
|
||
}
|
||
|
||
.col-name {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.name-text {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.col-size,
|
||
.col-date {
|
||
color: var(--github-text-secondary);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.directory-empty {
|
||
padding: 40px 24px;
|
||
color: var(--github-text-secondary);
|
||
}
|
||
|
||
.github-file-header {
|
||
border-bottom: 1px solid var(--github-border);
|
||
background: var(--github-bg);
|
||
}
|
||
|
||
.file-header-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
padding: 12px 20px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.sidebar-toggle-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 32px;
|
||
height: 32px;
|
||
border: 1px solid var(--github-border);
|
||
border-radius: 8px;
|
||
background: var(--github-bg);
|
||
color: var(--github-text-secondary);
|
||
cursor: pointer;
|
||
}
|
||
|
||
.sidebar-toggle-btn:hover {
|
||
background: var(--github-hover);
|
||
}
|
||
|
||
.file-header-main {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14px;
|
||
min-width: 0;
|
||
flex: 1;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.file-path {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
min-width: 0;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.breadcrumb-link {
|
||
border: none;
|
||
background: transparent;
|
||
color: #0969da;
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.breadcrumb-current {
|
||
color: var(--github-text);
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.path-sep,
|
||
.file-meta {
|
||
color: var(--github-text-secondary);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.file-header-meta {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.file-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
margin-left: auto;
|
||
}
|
||
|
||
.action-btn {
|
||
height: 32px;
|
||
padding: 0 12px;
|
||
border: 1px solid var(--github-border);
|
||
border-radius: 8px;
|
||
background: var(--github-bg);
|
||
color: var(--github-text);
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.action-btn:hover:enabled {
|
||
background: var(--github-hover);
|
||
}
|
||
|
||
.action-btn:disabled {
|
||
opacity: 0.45;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.content-markdown {
|
||
padding: 24px;
|
||
}
|
||
|
||
.markdown-body {
|
||
max-width: 920px;
|
||
margin: 0 auto;
|
||
padding: 28px 32px;
|
||
border: 1px solid var(--github-border);
|
||
border-radius: 12px;
|
||
background: var(--github-bg);
|
||
line-height: 1.8;
|
||
color: var(--github-text);
|
||
}
|
||
|
||
.markdown-body :deep(h1),
|
||
.markdown-body :deep(h2),
|
||
.markdown-body :deep(h3) {
|
||
margin-top: 1.4em;
|
||
margin-bottom: 0.6em;
|
||
}
|
||
|
||
.markdown-body :deep(pre) {
|
||
overflow: auto;
|
||
padding: 16px;
|
||
border-radius: 10px;
|
||
background: var(--github-code-bg);
|
||
}
|
||
|
||
.content-text {
|
||
padding: 16px;
|
||
}
|
||
|
||
.code-container {
|
||
display: grid;
|
||
grid-template-columns: 72px minmax(0, 1fr);
|
||
min-height: 100%;
|
||
border: 1px solid var(--github-border);
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
background: var(--github-bg);
|
||
}
|
||
|
||
.line-numbers {
|
||
padding: 18px 0;
|
||
background: var(--github-hover);
|
||
color: var(--github-text-secondary);
|
||
text-align: right;
|
||
user-select: none;
|
||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.line-number {
|
||
padding: 0 14px 0 0;
|
||
line-height: 1.65;
|
||
}
|
||
|
||
.text-content {
|
||
margin: 0;
|
||
padding: 18px 20px;
|
||
overflow: auto;
|
||
white-space: pre;
|
||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||
font-size: 13px;
|
||
line-height: 1.65;
|
||
color: var(--github-text);
|
||
}
|
||
|
||
.content-preview {
|
||
padding: 20px;
|
||
}
|
||
|
||
.preview-surface {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-height: 100%;
|
||
border: 1px solid var(--github-border);
|
||
border-radius: 12px;
|
||
background:
|
||
linear-gradient(45deg, rgba(208, 215, 222, 0.35) 25%, transparent 25%, transparent 75%, rgba(208, 215, 222, 0.35) 75%),
|
||
linear-gradient(45deg, rgba(208, 215, 222, 0.35) 25%, transparent 25%, transparent 75%, rgba(208, 215, 222, 0.35) 75%);
|
||
background-position: 0 0, 12px 12px;
|
||
background-size: 24px 24px;
|
||
}
|
||
|
||
.image-surface img {
|
||
max-width: 100%;
|
||
max-height: 76vh;
|
||
object-fit: contain;
|
||
border-radius: 8px;
|
||
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.12);
|
||
}
|
||
|
||
.image-surface.image-editor-active {
|
||
display: block;
|
||
padding: 0;
|
||
background: transparent;
|
||
border: none;
|
||
}
|
||
|
||
.image-surface.image-editor-active img {
|
||
display: none;
|
||
}
|
||
|
||
.image-inline-error {
|
||
margin-bottom: 14px;
|
||
padding: 12px 14px;
|
||
border: 1px solid color-mix(in srgb, #cf222e 35%, var(--github-border) 65%);
|
||
border-radius: 10px;
|
||
background: color-mix(in srgb, #cf222e 8%, var(--github-bg) 92%);
|
||
color: #cf222e;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.video-surface {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
justify-content: flex-start;
|
||
gap: 20px;
|
||
min-height: 72vh;
|
||
padding: 24px;
|
||
background:
|
||
radial-gradient(circle at top, rgba(9, 105, 218, 0.08), transparent 38%),
|
||
linear-gradient(180deg, rgba(13, 17, 23, 0.04), rgba(13, 17, 23, 0.08));
|
||
}
|
||
|
||
.video-player {
|
||
align-self: center;
|
||
width: min(1100px, 100%);
|
||
max-height: 78vh;
|
||
border-radius: 14px;
|
||
background: #000;
|
||
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.22);
|
||
}
|
||
|
||
.video-edit-panel {
|
||
width: min(1100px, 100%);
|
||
margin: 0 auto;
|
||
padding: 20px;
|
||
border: 1px solid var(--github-border);
|
||
border-radius: 16px;
|
||
background: color-mix(in srgb, var(--github-bg) 94%, white 6%);
|
||
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.08);
|
||
}
|
||
|
||
.video-edit-header {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
margin-bottom: 18px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.video-edit-header h3 {
|
||
margin: 0;
|
||
}
|
||
|
||
.video-edit-header p {
|
||
margin: 6px 0 0;
|
||
color: var(--github-text-secondary);
|
||
line-height: 1.7;
|
||
}
|
||
|
||
.video-edit-meta {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.video-edit-meta span {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
min-height: 28px;
|
||
padding: 0 10px;
|
||
border: 1px solid var(--github-border);
|
||
border-radius: 999px;
|
||
background: var(--github-hover);
|
||
color: var(--github-text-secondary);
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.video-edit-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 14px;
|
||
}
|
||
|
||
.video-field {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
color: var(--github-text-secondary);
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.video-field input {
|
||
height: 40px;
|
||
padding: 0 12px;
|
||
border: 1px solid var(--github-border);
|
||
border-radius: 10px;
|
||
background: var(--github-bg);
|
||
color: var(--github-text);
|
||
}
|
||
|
||
.video-quick-actions,
|
||
.video-edit-actions {
|
||
display: flex;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.video-quick-actions {
|
||
margin-top: 14px;
|
||
}
|
||
|
||
.ghost-btn {
|
||
height: 34px;
|
||
padding: 0 12px;
|
||
border: 1px solid var(--github-border);
|
||
border-radius: 999px;
|
||
background: transparent;
|
||
color: var(--github-text-secondary);
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.ghost-btn:hover:enabled {
|
||
background: var(--github-hover);
|
||
color: var(--github-text);
|
||
}
|
||
|
||
.ghost-btn:disabled {
|
||
opacity: 0.45;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.video-checkbox {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
margin-top: 14px;
|
||
color: var(--github-text);
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.video-inline-error {
|
||
margin-top: 14px;
|
||
padding: 12px 14px;
|
||
border: 1px solid color-mix(in srgb, #cf222e 35%, var(--github-border) 65%);
|
||
border-radius: 10px;
|
||
background: color-mix(in srgb, #cf222e 8%, var(--github-bg) 92%);
|
||
color: #cf222e;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.video-edit-progress-shell {
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.video-edit-progress-shell strong {
|
||
display: inline-block;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.video-edit-actions {
|
||
margin-top: 18px;
|
||
}
|
||
|
||
.primary-btn {
|
||
border-color: #0969da;
|
||
background: #0969da;
|
||
color: #fff;
|
||
}
|
||
|
||
.primary-btn:hover:enabled {
|
||
background: #0859ba;
|
||
}
|
||
|
||
.video-state-card {
|
||
width: min(520px, 100%);
|
||
padding: 24px;
|
||
border: 1px solid var(--github-border);
|
||
border-radius: 16px;
|
||
background: color-mix(in srgb, var(--github-bg) 92%, white 8%);
|
||
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.08);
|
||
text-align: center;
|
||
}
|
||
|
||
.video-state-card h3 {
|
||
margin: 0 0 10px;
|
||
}
|
||
|
||
.video-state-card p {
|
||
margin: 0;
|
||
color: var(--github-text-secondary);
|
||
line-height: 1.7;
|
||
}
|
||
|
||
.video-state-card strong {
|
||
display: block;
|
||
margin-top: 12px;
|
||
color: var(--github-text);
|
||
}
|
||
|
||
.video-state-error {
|
||
border-color: color-mix(in srgb, #cf222e 40%, var(--github-border) 60%);
|
||
}
|
||
|
||
.video-progress-track {
|
||
position: relative;
|
||
height: 10px;
|
||
margin-top: 18px;
|
||
overflow: hidden;
|
||
border-radius: 999px;
|
||
background: var(--github-hover);
|
||
}
|
||
|
||
.video-progress-bar {
|
||
display: block;
|
||
height: 100%;
|
||
border-radius: inherit;
|
||
background: linear-gradient(90deg, #0969da, #2da44e);
|
||
transition: width 180ms ease;
|
||
}
|
||
|
||
.pdf-frame {
|
||
width: 100%;
|
||
min-height: 78vh;
|
||
border: 1px solid var(--github-border);
|
||
border-radius: 12px;
|
||
background: var(--github-bg);
|
||
}
|
||
|
||
.content-unsupported {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 24px;
|
||
}
|
||
|
||
.unsupported-card {
|
||
width: min(640px, 100%);
|
||
padding: 28px;
|
||
border: 1px solid var(--github-border);
|
||
border-radius: 16px;
|
||
background: var(--github-bg);
|
||
text-align: center;
|
||
}
|
||
|
||
.unsupported-card h3 {
|
||
margin: 0 0 8px;
|
||
}
|
||
|
||
.unsupported-card p {
|
||
margin: 0;
|
||
color: var(--github-text-secondary);
|
||
}
|
||
|
||
.unsupported-meta {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
margin-top: 18px;
|
||
}
|
||
|
||
.unsupported-meta span,
|
||
.icon-folder,
|
||
.icon-file {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.unsupported-meta span {
|
||
height: 28px;
|
||
padding: 0 10px;
|
||
border: 1px solid var(--github-border);
|
||
border-radius: 999px;
|
||
background: var(--github-hover);
|
||
color: var(--github-text-secondary);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.icon-folder::before {
|
||
content: '📁';
|
||
font-size: 15px;
|
||
}
|
||
|
||
.icon-file::before {
|
||
content: '📄';
|
||
font-size: 14px;
|
||
}
|
||
|
||
.icon-markdown::before { content: 'Ⓜ'; font-size: 12px; font-weight: 700; color: #0969da; }
|
||
.icon-json::before { content: '{ }'; font-size: 8px; font-weight: 700; color: #8250df; }
|
||
.icon-javascript::before { content: 'JS'; font-size: 9px; font-weight: 700; color: #9a6700; }
|
||
.icon-typescript::before { content: 'TS'; font-size: 9px; font-weight: 700; color: #0969da; }
|
||
.icon-css::before { content: 'CSS'; font-size: 7px; font-weight: 700; color: #1f883d; }
|
||
.icon-html::before { content: 'HTML'; font-size: 6px; font-weight: 700; color: #bc4c00; }
|
||
.icon-python::before { content: 'PY'; font-size: 9px; font-weight: 700; color: #0969da; }
|
||
.icon-vue::before { content: 'Vue'; font-size: 8px; font-weight: 700; color: #1f883d; }
|
||
.icon-yaml::before { content: 'YML'; font-size: 8px; font-weight: 700; color: #0969da; }
|
||
.icon-xml::before { content: '</>'; font-size: 8px; font-weight: 700; color: #bc4c00; }
|
||
.icon-csv::before { content: 'CSV'; font-size: 8px; font-weight: 700; color: #1f883d; }
|
||
.icon-log::before { content: 'LOG'; font-size: 7px; font-weight: 700; color: #6e7781; }
|
||
.icon-sql::before { content: 'SQL'; font-size: 8px; font-weight: 700; color: #8250df; }
|
||
.icon-image::before { content: '🖼'; font-size: 13px; }
|
||
.icon-pdf::before { content: 'PDF'; font-size: 8px; font-weight: 700; color: #cf222e; }
|
||
.icon-video::before { content: '▶'; font-size: 11px; font-weight: 700; color: #0969da; }
|
||
.icon-word::before { content: 'DOC'; font-size: 7px; font-weight: 700; color: #0969da; }
|
||
.icon-ppt::before { content: 'PPT'; font-size: 7px; font-weight: 700; color: #bc4c00; }
|
||
.icon-excel::before { content: 'XLS'; font-size: 7px; font-weight: 700; color: #1f883d; }
|
||
.icon-zip::before { content: 'ZIP'; font-size: 7px; font-weight: 700; color: #6f42c1; }
|
||
.icon-text::before { content: 'TXT'; font-size: 8px; font-weight: 700; color: #6e7781; }
|
||
|
||
@media (max-width: 960px) {
|
||
.file-header-row,
|
||
.directory-card-header {
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.file-header-main,
|
||
.file-actions {
|
||
width: 100%;
|
||
}
|
||
|
||
.file-actions {
|
||
margin-left: 0;
|
||
}
|
||
|
||
.directory-row {
|
||
grid-template-columns: minmax(0, 1fr);
|
||
padding-top: 10px;
|
||
padding-bottom: 10px;
|
||
}
|
||
|
||
.col-size,
|
||
.col-date {
|
||
font-size: 12px;
|
||
}
|
||
|
||
.code-container {
|
||
grid-template-columns: 56px minmax(0, 1fr);
|
||
}
|
||
}
|
||
</style>
|