fix: frontend
This commit is contained in:
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';
|
||||
Reference in New Issue
Block a user