fix: frontend
This commit is contained in:
3
.vscode/extensions.json
vendored
Normal file
3
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
7
.vscode/launch.json
vendored
7
.vscode/launch.json
vendored
@@ -11,9 +11,10 @@
|
||||
"mode": "auto",
|
||||
"program": "${workspaceFolder}/main.go",
|
||||
"args": [
|
||||
"channel",
|
||||
"add",
|
||||
"--alias=chiguamaopao"
|
||||
"serve"
|
||||
// "channel",
|
||||
// "add",
|
||||
// "--alias=chiguamaopao"
|
||||
// https://t.me/yunpanshare/26640
|
||||
// "--alias", "yunpanshare",
|
||||
// https://t.me/Aliyun_4K_Movies/26640
|
||||
|
||||
BIN
__debug_bin1282941245
Executable file
BIN
__debug_bin1282941245
Executable file
Binary file not shown.
10
frontend/.fun.yaml
Normal file
10
frontend/.fun.yaml
Normal 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
24
frontend/.gitignore
vendored
Normal 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
5
frontend/README.md
Normal 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
6
frontend/a.js
Normal 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
37
frontend/data.json
Normal 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
13
frontend/index.html
Normal 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
3380
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
frontend/package.json
Normal file
30
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal 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
11
frontend/src/App.vue
Normal 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>
|
||||
46
frontend/src/components/ListItem.vue
Normal file
46
frontend/src/components/ListItem.vue
Normal 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>
|
||||
44
frontend/src/components/MediaDocument.vue
Normal file
44
frontend/src/components/MediaDocument.vue
Normal 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>
|
||||
45
frontend/src/components/MediaGrid.vue
Normal file
45
frontend/src/components/MediaGrid.vue
Normal 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>
|
||||
34
frontend/src/components/MediaItem.vue
Normal file
34
frontend/src/components/MediaItem.vue
Normal 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>
|
||||
84
frontend/src/components/MediaPhoto.vue
Normal file
84
frontend/src/components/MediaPhoto.vue
Normal 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>
|
||||
45
frontend/src/components/Navigation.vue
Normal file
45
frontend/src/components/Navigation.vue
Normal 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
37
frontend/src/data.ts
Normal 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
8
frontend/src/main.ts
Normal 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
38
frontend/src/request.ts
Normal 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
19
frontend/src/router.ts
Normal 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
3
frontend/src/style.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
37
frontend/src/types.ts
Normal file
37
frontend/src/types.ts
Normal 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;
|
||||
}
|
||||
37
frontend/src/views/ChannelMessages.vue
Normal file
37
frontend/src/views/ChannelMessages.vue
Normal 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>
|
||||
29
frontend/src/views/FavoritesMessages.vue
Normal file
29
frontend/src/views/FavoritesMessages.vue
Normal 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>
|
||||
27
frontend/src/views/Home.vue
Normal file
27
frontend/src/views/Home.vue
Normal 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>
|
||||
9
frontend/src/views/Tag.vue
Normal file
9
frontend/src/views/Tag.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
|
||||
<template>
|
||||
<h1>Tag: {{ route.params.tag }}</h1>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRoute } from 'vue-router';
|
||||
const route = useRoute();
|
||||
</script>
|
||||
4
frontend/src/views/Tags.vue
Normal file
4
frontend/src/views/Tags.vue
Normal file
@@ -0,0 +1,4 @@
|
||||
|
||||
<template>
|
||||
<h1>Tags</h1>
|
||||
</template>
|
||||
13
frontend/src/vite-env.d.ts
vendored
Normal file
13
frontend/src/vite-env.d.ts
vendored
Normal 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';
|
||||
16
frontend/tailwind.config.js
Normal file
16
frontend/tailwind.config.js
Normal 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: [],
|
||||
}
|
||||
24
frontend/tsconfig.app.json
Normal file
24
frontend/tsconfig.app.json
Normal 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
19
frontend/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.d.ts",
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.vue"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
22
frontend/tsconfig.node.json
Normal file
22
frontend/tsconfig.node.json
Normal 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
19
frontend/vite.config.ts
Normal 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, //需要代理跨域
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
25
home.text
Normal file
25
home.text
Normal file
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<div class="flex flex-col space-y-3">
|
||||
<ListItem v-for="item in items" :key="item.id" :item="item" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PostItem } from '@/types'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import ListItem from '../components/ListItem.vue'
|
||||
import data from '../data'
|
||||
import { } from
|
||||
|
||||
const items = ref<PostItem[]>([])
|
||||
|
||||
onMounted(() => {
|
||||
items.value = [
|
||||
{
|
||||
id: 1,
|
||||
content: "Hello Man\nHow are you doing",
|
||||
medias: data,
|
||||
},
|
||||
]
|
||||
})
|
||||
</script>
|
||||
@@ -29,7 +29,6 @@ func PublishCmd() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,24 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type Pagination struct {
|
||||
Page int64 `json:"page"`
|
||||
Limit int64 `json:"limit"`
|
||||
Offset int64 `json:"-"`
|
||||
}
|
||||
|
||||
func (p *Pagination) Format() {
|
||||
if p.Limit == 0 {
|
||||
p.Limit = 10
|
||||
}
|
||||
|
||||
if p.Page == 0 {
|
||||
p.Offset = 0
|
||||
} else {
|
||||
p.Offset = (p.Page - 1) * p.Limit
|
||||
}
|
||||
}
|
||||
|
||||
func ServeCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "serve",
|
||||
@@ -35,10 +53,16 @@ func serveCmd(cmd *cobra.Command, args []string) error {
|
||||
|
||||
app := fiber.New()
|
||||
|
||||
app.Static("/medias", "/share/telegram/outputs")
|
||||
app.Static("/", "/public")
|
||||
|
||||
// Initialize default config
|
||||
app.Use(recover.New())
|
||||
|
||||
app.Get("/channels", func(c *fiber.Ctx) error {
|
||||
group := app.Group("/api")
|
||||
|
||||
// get channel list
|
||||
group.Get("/channels", func(c *fiber.Ctx) error {
|
||||
var channels []model.Channels
|
||||
|
||||
tbl := table.Channels
|
||||
@@ -49,9 +73,9 @@ func serveCmd(cmd *cobra.Command, args []string) error {
|
||||
return c.JSON(channels)
|
||||
})
|
||||
|
||||
// 获取频道最新一条数据
|
||||
app.Get("/channels/:id", func(c *fiber.Ctx) error {
|
||||
channelID, err := c.ParamsInt("id")
|
||||
// get channel info
|
||||
group.Get("/channels/:channel", func(c *fiber.Ctx) error {
|
||||
channelID, err := c.ParamsInt("channel")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -61,7 +85,7 @@ func serveCmd(cmd *cobra.Command, args []string) error {
|
||||
tbl := table.Channels
|
||||
err = tbl.
|
||||
SELECT(tbl.AllColumns).
|
||||
WHERE(tbl.ID.EQ(Int64(int64(channelID)))).
|
||||
WHERE(tbl.UUID.EQ(Int64(int64(channelID)))).
|
||||
QueryContext(c.Context(), db, &channel)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -70,35 +94,45 @@ func serveCmd(cmd *cobra.Command, args []string) error {
|
||||
return c.JSON(channel)
|
||||
})
|
||||
|
||||
app.Get("/channels/:id/message", func(c *fiber.Ctx) error {
|
||||
channelID, err := c.ParamsInt("id")
|
||||
// get channel's post list
|
||||
group.Get("/channels/:channel/messages", func(c *fiber.Ctx) error {
|
||||
channelID, err := c.ParamsInt("channel")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var msg model.ChannelMessages
|
||||
var p Pagination
|
||||
if err := c.QueryParser(&p); err != nil {
|
||||
return err
|
||||
}
|
||||
p.Format()
|
||||
|
||||
var messages []model.ChannelMessages
|
||||
|
||||
tbl := table.ChannelMessages
|
||||
err = tbl.
|
||||
SELECT(tbl.AllColumns).
|
||||
WHERE(tbl.ChannelID.EQ(Int64(int64(channelID)))).
|
||||
ORDER_BY(tbl.ID.DESC()).
|
||||
LIMIT(1).
|
||||
QueryContext(c.Context(), db, &msg)
|
||||
LIMIT(p.Limit).
|
||||
OFFSET(p.Offset).
|
||||
QueryContext(c.Context(), db, &messages)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.JSON(msg)
|
||||
return c.JSON(messages)
|
||||
})
|
||||
|
||||
app.Get("/channels/:id/message/like", func(c *fiber.Ctx) error {
|
||||
channelID, err := c.ParamsInt("id")
|
||||
if err != nil {
|
||||
// favorite messages
|
||||
group.Get("/favorites", func(c *fiber.Ctx) error {
|
||||
var p Pagination
|
||||
if err := c.QueryParser(&p); err != nil {
|
||||
return err
|
||||
}
|
||||
p.Format()
|
||||
|
||||
var msg model.ChannelMessages
|
||||
var messages []model.ChannelMessages
|
||||
|
||||
tbl := table.ChannelMessages
|
||||
err = tbl.
|
||||
@@ -109,23 +143,19 @@ func serveCmd(cmd *cobra.Command, args []string) error {
|
||||
),
|
||||
).
|
||||
ORDER_BY(tbl.ID.DESC()).
|
||||
LIMIT(1).
|
||||
QueryContext(c.Context(), db, &msg)
|
||||
LIMIT(p.Limit).
|
||||
OFFSET(p.Offset).
|
||||
QueryContext(c.Context(), db, &messages)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.JSON(msg)
|
||||
return c.JSON(messages)
|
||||
})
|
||||
|
||||
// toggle msg likes
|
||||
app.Patch("/channels/:channelID/message/:id/like", func(c *fiber.Ctx) error {
|
||||
channelID, err := c.ParamsInt("channelID")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
msgID, err := c.ParamsInt("id")
|
||||
// toggle favorite message
|
||||
group.Patch("message/:message/favorite", func(c *fiber.Ctx) error {
|
||||
messageID, err := c.ParamsInt("message")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -133,11 +163,10 @@ func serveCmd(cmd *cobra.Command, args []string) error {
|
||||
var msg model.ChannelMessages
|
||||
|
||||
tbl := table.ChannelMessages
|
||||
cond := tbl.ChannelID.EQ(Int64(int64(channelID))).AND(tbl.ID.EQ(Int64(int64(msgID))))
|
||||
|
||||
err = tbl.
|
||||
SELECT(tbl.AllColumns).
|
||||
WHERE(cond).
|
||||
WHERE(tbl.ID.EQ(Int64(int64(messageID)))).
|
||||
ORDER_BY(tbl.ID.DESC()).
|
||||
LIMIT(1).
|
||||
QueryContext(c.Context(), db, &msg)
|
||||
@@ -148,7 +177,7 @@ func serveCmd(cmd *cobra.Command, args []string) error {
|
||||
_, err = tbl.
|
||||
UPDATE().
|
||||
SET(tbl.Like.SET(Bool(!msg.Like))).
|
||||
WHERE(cond).
|
||||
WHERE(tbl.ID.EQ(Int64(int64(messageID)))).
|
||||
ExecContext(c.Context(), db)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -158,23 +187,16 @@ func serveCmd(cmd *cobra.Command, args []string) error {
|
||||
})
|
||||
|
||||
// delete message
|
||||
app.Delete("/channels/:channelID/message/:id", func(c *fiber.Ctx) error {
|
||||
channelID, err := c.ParamsInt("channelID")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
msgID, err := c.ParamsInt("id")
|
||||
group.Delete("/messages/:message", func(c *fiber.Ctx) error {
|
||||
msgID, err := c.ParamsInt("message")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tbl := table.ChannelMessages
|
||||
cond := tbl.ChannelID.EQ(Int64(int64(channelID))).AND(tbl.ID.EQ(Int64(int64(msgID))))
|
||||
|
||||
_, err = tbl.
|
||||
DELETE().
|
||||
WHERE(cond).
|
||||
WHERE(tbl.ID.EQ(Int64(int64(msgID)))).
|
||||
ExecContext(c.Context(), db)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -17,7 +17,7 @@ func TestDBChannel_SaveMessage(t *testing.T) {
|
||||
if err := InitDB(dsn); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
db.Exec(`truncate channel_messages`)
|
||||
_, _ = db.Exec(`truncate channel_messages`)
|
||||
|
||||
msg := &ChannelMessage{
|
||||
ID: 1,
|
||||
|
||||
Reference in New Issue
Block a user