fix: frontend

This commit is contained in:
Rogee
2024-09-20 01:04:28 +08:00
parent b8499ca97d
commit 146b0ed55a
42 changed files with 4276 additions and 44 deletions

10
frontend/.fun.yaml Normal file
View File

@@ -0,0 +1,10 @@
gen:
model:
dsn: __DSN
source: postgres
schema: public
path: ./database
ignores: [] # ignore tables
types:
kube_pods: # table name
labels: functl/pkg/pg.JsonMap # column type

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

5
frontend/README.md Normal file
View File

@@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).

6
frontend/a.js Normal file
View File

@@ -0,0 +1,6 @@
let str = "[{\"photo\": \"6274031756223036782.jpg\", \"msg_id\": 26781, \"asset_id\": 6274031756223036782}, {\"photo\": \"6274031756223036783.jpg\", \"msg_id\": 26782, \"asset_id\": 6274031756223036783}, {\"photo\": \"6274031756223036784.jpg\", \"msg_id\": 26783, \"asset_id\": 6274031756223036784}, {\"photo\": \"6274031756223036785.jpg\", \"msg_id\": 26784, \"asset_id\": 6274031756223036785}, {\"photo\": \"6274031756223036786.jpg\", \"msg_id\": 26785, \"asset_id\": 6274031756223036786}, {\"photo\": \"6274031756223036787.jpg\", \"msg_id\": 26786, \"asset_id\": 6274031756223036787}, {\"photo\": \"6274031756223036788.jpg\", \"msg_id\": 26787, \"asset_id\": 6274031756223036788}, {\"photo\": \"6274031756223036789.jpg\", \"msg_id\": 26788, \"asset_id\": 6274031756223036789}, {\"photo\": \"6274031756223036790.jpg\", \"msg_id\": 26789, \"asset_id\": 6274031756223036790}, {\"photo\": \"6274031756223036791.jpg\", \"msg_id\": 26790, \"asset_id\": 6274031756223036791}]"
str = str.replace(/"asset_id":\s(\d+)/g, (match, p1, p2, p3, offset, string) => {
return `"asset_id": "${p1}"`
})
console.log(str)

37
frontend/data.json Normal file
View File

@@ -0,0 +1,37 @@
[
{
"photo": "6330272214070443931.jpg",
"msg_id": 26701,
"asset_id": 6330272214070443931
},
{
"photo": "6330272214070443932.jpg",
"msg_id": 26702,
"asset_id": 6330272214070443932
},
{
"photo": "6330272214070443933.jpg",
"msg_id": 26703,
"asset_id": 6330272214070443933
},
{
"photo": "6330272214070443934.jpg",
"msg_id": 26704,
"asset_id": 6330272214070443934
},
{
"msg_id": 26705,
"asset_id": 6330272213614202883,
"document": {
"Ext": ".mp4",
"Size": 2235348,
"Video": {
"Width": 576,
"Height": 1280,
"Duration": 25.147
},
"Filename": "",
"MimeType": "video/mp4"
}
}
]

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Vue + TS</title>
</head>
<body class="bg-slate-300">
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

3380
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
frontend/package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "tg-show",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@headlessui/vue": "^1.7.23",
"@heroicons/vue": "^2.1.5",
"axios": "^1.7.7",
"vue": "^3.4.37",
"vue-router": "^4.4.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.2",
"autoprefixer": "^10.4.20",
"nodemon": "^3.1.6",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.12",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.5.3",
"vite": "^5.4.1",
"vue-tsc": "^2.0.29"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

1
frontend/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

11
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
import Navigation from './components/Navigation.vue';
</script>
<template>
<Navigation />
<main class="p-4 mt-16 md:max-w-screen-md mx-auto">
<RouterView />
</main>
</template>

View File

@@ -0,0 +1,46 @@
<template>
<div class="bg-slate-100 border rounded mb-4 p-2 md:mb-8 md:p-4">
<div
v-if="item.Content.length > 0"
class="text-wrap font-sans"
v-html="processedContent"
></div>
<MediaGrid :medias="item.Media" :channel="item.ChannelID" />
</div>
</template>
<script lang="ts">
import { PostItem } from "@/types";
import { defineComponent, computed, PropType } from "vue";
import MediaGrid from "./MediaGrid.vue";
function nl2br(str, is_xhtml) {
var breakTag = is_xhtml || typeof is_xhtml === "undefined" ? "<br />" : "<br>";
return (str + "").replace(/([^>\r\n]?)(\r\n|\n\r|\r|\n)/g, "$1" + breakTag + "$2");
}
export default defineComponent({
components: {
MediaGrid,
},
name: "ListItem",
props: {
item: {
type: Object as PropType<PostItem>,
required: true,
},
},
setup(props) {
const processedContent = computed(() => {
let content = props.item.Content.trim();
return nl2br(content);
});
return {
item: props.item,
processedContent,
};
},
});
</script>

View File

@@ -0,0 +1,44 @@
<template>
<div
v-if="hideVideo"
@click="hideVideo = false"
class="bg-gray-500 min-h-full cursor-pointer flex justify-center align-center"
>
<PlayIcon class="text-gray-400 w-1/2" />
</div>
<video v-else controls class="min-w-full min-h-full">
<source :src="videoSrc()" :type="media.document.MimeType" />
</video>
</template>
<script lang="ts">
import { Media } from "@/types";
import { PlayIcon } from "@heroicons/vue/24/outline";
import { defineComponent, PropType, ref } from "vue";
export default defineComponent({
components: {
PlayIcon,
},
name: "MediaDocument",
props: {
channel: { required: true },
media: {
type: Object as PropType<Media>,
required: true,
},
},
setup(props) {
const videoSrc = () => {
return `/medias/${props.channel}/${props.media.asset_id}${props.media.document.Ext}`;
};
const hideVideo = ref(true);
return {
videoSrc,
hideVideo,
};
},
});
</script>

View File

@@ -0,0 +1,45 @@
<template>
<div v-if="medias.length > 0" class="mt-2 md:mt-4">
<template v-if="medias.length == 1">
<template v-for="media in medias" :key="media.id">
<MediaItem :media="media" :channel="channel" />
</template>
</template>
<template v-else-if="medias.length % 2 == 0 && medias.length % 3 != 0">
<div class="medias grid grid-cols-2 gap-2 md:gap-4">
<template v-for="media in medias" :key="media.id">
<MediaItem :media="media" :channel="channel" />
</template>
</div>
</template>
<template v-else>
<div class="medias grid grid-cols-3 gap-2 md:gap-4">
<template v-for="media in medias" :key="media.id">
<MediaItem :media="media" :channel="channel" />
</template>
</div>
</template>
</div>
</template>
<script lang="ts">
import { Media } from "@/types";
import { defineComponent, PropType } from "vue";
import MediaItem from "./MediaItem.vue";
export default defineComponent({
components: {
MediaItem,
},
name: "MediaGrid",
props: {
channel: { required: true },
medias: {
type: Object as PropType<Media[]>,
required: true,
},
},
});
</script>

View File

@@ -0,0 +1,34 @@
<template>
<div
class="max-w-full overflow-hidden hide-scrollbar rounded-sm hover:shadow-lg aspect-3/4 bg-gray-500 min-h-full cursor-pointer flex justify-center align-center"
>
<MediaPhoto v-if="media.photo?.length > 0" :media="media" :channel="channel" />
<MediaDocument
v-else-if="typeof media.document != undefined"
:media="media"
:channel="channel"
/>
</div>
</template>
<script lang="ts">
import { Media } from "@/types";
import { defineComponent, PropType } from "vue";
import MediaDocument from "./MediaDocument.vue";
import MediaPhoto from "./MediaPhoto.vue";
export default defineComponent({
components: {
MediaPhoto,
MediaDocument,
},
name: "MediaItem",
props: {
channel: { required: true },
media: {
type: Object as PropType<Media>,
required: true,
},
},
});
</script>

View File

@@ -0,0 +1,84 @@
<template>
<img
:src="photoSrc()"
class="max-w-full h-full max-h-full"
loading="lazy"
decoding="async"
@click="openPreview"
/>
<div v-if="isPreviewVisible" class="modal" @click="closePreview">
<img :src="photoSrc()" alt="Preview" class="preview-image" />
</div>
</template>
<script lang="ts">
import { Media } from "@/types";
import { defineComponent, nextTick, PropType, ref } from "vue";
export default defineComponent({
name: "MediaPhoto",
props: {
channel: { required: true },
media: {
type: Object as PropType<Media>,
required: true,
},
},
setup(props) {
const photoSrc = () => {
return `/medias/${props.channel}/${props.media.photo}`;
};
const isPreviewVisible = ref(false);
const openPreview = () => {
isPreviewVisible.value = true;
nextTick(() => {
const previewImage = document.querySelector(".preview-image");
if (previewImage) {
previewImage.requestFullscreen();
}
});
};
const closePreview = () => {
isPreviewVisible.value = false;
if (document.fullscreenElement) {
document.exitFullscreen();
}
};
return {
isPreviewVisible,
openPreview,
closePreview,
photoSrc,
};
},
});
</script>
<style scoped>
.thumbnail {
cursor: pointer;
width: 200px;
/* Adjust as needed */
}
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
display: flex;
justify-content: center;
align-items: center;
}
.preview-image {
max-width: 100%;
max-height: 100%;
}
</style>

View File

@@ -0,0 +1,45 @@
<!-- This example requires Tailwind CSS v2.0+ -->
<template>
<Disclosure as="nav" class="bg-gray-800 fixed w-full top-0" v-slot="{ open }">
<nav class="md:max-w-screen-md mx-auto">
<div class="flex items-center justify-between h-16 px-4">
<div class="flex space-x-4">
<RouterLink
v-for="item in navigation"
:key="item.name"
:to="item.href"
:class="[
item.current
? 'bg-gray-900 text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
'px-3 py-2 rounded-md text-sm font-medium cursor-pointer',
]"
:aria-current="item.current ? 'page' : undefined"
>{{ item.name }}</RouterLink
>
</div>
</div>
</nav>
</Disclosure>
</template>
<script>
import { Disclosure } from "@headlessui/vue";
import { RouterLink } from "vue-router";
const navigation = [
{ name: "Home", href: "/", current: false },
{ name: "Favorites", href: "/favorites", current: false },
];
export default {
components: {
RouterLink,
Disclosure,
},
setup() {
return {
navigation,
};
},
};
</script>

37
frontend/src/data.ts Normal file
View File

@@ -0,0 +1,37 @@
export default [
{
"photo": "https://picsum.photos/300/300",
"msg_id": 26701,
"asset_id": 6330272214070443931
},
{
"photo": "https://picsum.photos/300/300",
"msg_id": 26702,
"asset_id": 6330272214070443932
},
{
"photo": "https://picsum.photos/300/300",
"msg_id": 26703,
"asset_id": 6330272214070443933
},
{
"photo": "https://picsum.photos/300/300",
"msg_id": 26704,
"asset_id": 6330272214070443934
},
{
"msg_id": 26705,
"asset_id": 6330272213614202883,
"document": {
"Ext": ".mp4",
"Size": 2235348,
"Video": {
"Width": 576,
"Height": 1280,
"Duration": 25.147
},
"Filename": "",
"MimeType": "video/mp4"
}
}
]

8
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,8 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import './style.css'
createApp(App)
.use(router)
.mount('#app')

38
frontend/src/request.ts Normal file
View File

@@ -0,0 +1,38 @@
import axios from 'axios';
axios.defaults.baseURL = '/api';
axios.defaults.headers.post['Content-Type'] = 'application/json';
axios.defaults.headers.put['Content-Type'] = 'application/json';
export interface Pagination {
page: number;
limit: number;
}
export function channels() {
return axios.get('/channels')
}
export function channelInfo(id) {
return axios.get(`/channels/${id}`);
}
export function channelMessages(channelId, pagination: Pagination) {
return axios.get(`/channels/${channelId}/messages`, {
params: pagination,
});
}
export function favoriteMessages(pagination: Pagination) {
return axios.get('/favorites', {
params: pagination,
});
}
export function toggleFavoriteMessage(messageId) {
return axios.patch(`/channels/${messageId}/messages`);
}
export function deleteMessage(messageID) {
return axios.delete(`/messages/${messageID}`);
}

19
frontend/src/router.ts Normal file
View File

@@ -0,0 +1,19 @@
import { createRouter, createWebHistory } from 'vue-router'
import ChannelMessages from './views/ChannelMessages.vue'
import FavoritesMessages from './views/FavoritesMessages.vue'
import Home from './views/Home.vue'
const routes = [
{ path: '/', component: Home, name: 'home' },
{ path: '/favorites', component: FavoritesMessages, name: 'favorites' },
// { path: '/channels', component: Channel, name: 'channels' },
{ path: '/channels/:channel/messages', component: ChannelMessages, name: 'channel-messages' },
]
const router = createRouter({
history: createWebHistory(),
routes,
})
export default router

3
frontend/src/style.css Normal file
View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

37
frontend/src/types.ts Normal file
View File

@@ -0,0 +1,37 @@
export interface Channel {
UUID: number;
Username: string;
Title: string;
CreatedAt: Date;
UpdatedAt: Date;
Offset: number;
MinID: number;
ExportMedia: boolean;
}
export interface PostItem {
id: number;
content: string;
medias: Array<Media>;
}
export interface Media {
photo?: string;
msg_id: number;
asset_id: number;
document?: Document;
}
export interface Document {
Ext: string;
Size: number;
Video: Video;
Filename: string;
MimeType: string;
}
export interface Video {
Width: number;
Height: number;
Duration: number;
}

View File

@@ -0,0 +1,37 @@
<template>
<h1 class="mb-4 font-semibold text-xl">{{ channel.Title }}</h1>
<ListItem v-for="item in items" :key="item.id" :item="item" />
</template>
<script setup>
import { useRoute } from "vue-router";
import { onMounted, ref } from "vue";
import { channelMessages, channelInfo } from "../request";
import ListItem from "../components/ListItem.vue";
const route = useRoute();
const channel = ref({});
const items = ref([]);
onMounted(() => {
channelInfo(route.params.channel).then((resp) => {
channel.value = resp.data;
});
channelMessages(route.params.channel).then((resp) => {
let data = resp.data;
data.map((item) => {
let media = item.Media.replace(/"asset_id":\s(\d+)/g, (match, p1, p2, p3, offset, string) => {
return `"asset_id": "${p1}"`
})
item.Media = JSON.parse(media).filter((item) => {
return Object.keys(item).length > 0;
});
console.log(item);
});
items.value = data;
});
});
</script>

View File

@@ -0,0 +1,29 @@
<template>
<h1 class="mb-4 font-semibold text-xl">Favorites</h1>
<ListItem v-for="item in items" :key="item.id" :item="item" />
<div v-if="items.length==0">Empty...</div>
</template>
<script setup>
import { useRoute } from 'vue-router';
import { onMounted, ref } from 'vue';
import { favoriteMessages } from '../request';
import ListItem from '../components/ListItem.vue';
const route = useRoute();
const items = ref([])
onMounted(() => {
favoriteMessages().then(resp => {
let data = resp.data
data.map(item => {
item.Media = JSON.parse(item.Media).filter(item => {
return Object.keys(item).length > 0
})
})
items.value = data
})
})
</script>

View File

@@ -0,0 +1,27 @@
<template>
<div class="grid grid-cols-2 gap-4">
<RouterLink
:to="`/channels/${item.UUID}/messages`"
v-for="item in items"
:key="item.UUID"
class="border p-4 hover:shadow-lg hover:bg-slate-100 bg-slate-100 rounded"
>
<h2 class="font-semibold text-lg">{{ item.Title }}</h2>
<span>{{ item.Username }}</span>
</RouterLink>
</div>
</template>
<script setup lang="ts">
import { channels } from "../request";
import { Channel } from "@/types";
import { onMounted, ref } from "vue";
const items = ref<Channel[]>([]);
onMounted(() => {
channels().then((resp) => {
items.value = resp.data;
});
});
</script>

View File

@@ -0,0 +1,9 @@
<template>
<h1>Tag: {{ route.params.tag }}</h1>
</template>
<script setup>
import { useRoute } from 'vue-router';
const route = useRoute();
</script>

View File

@@ -0,0 +1,4 @@
<template>
<h1>Tags</h1>
</template>

13
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
declare module './types.ts';

View File

@@ -0,0 +1,16 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {
aspectRatio: {
'3/4': '3 / 4',
'9/16': '9 / 16',
},
},
},
plugins: [],
}

View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

19
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"src/**/*.ts",
"src/**/*.d.ts",
"src/**/*.tsx",
"src/**/*.vue"
],
"exclude": [
"node_modules"
]
}

View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

19
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,19 @@
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
'/api': {
target: "http://127.0.0.1:3000/", //目标域名
changeOrigin: true, //需要代理跨域
},
'/medias': {
target: "http://127.0.0.1:3000/", //目标域名
changeOrigin: true, //需要代理跨域
},
},
},
})