├── .npmrc ├── assets ├── css │ ├── index.css │ ├── transitions │ │ ├── index.css │ │ ├── list.css │ │ ├── slide-x.css │ │ ├── slide-y.css │ │ ├── fade.css │ │ └── page.css │ ├── tailwind.css │ └── font.css └── fonts │ ├── Mulish-VariableFont_wght.ttf │ ├── RedHatMono-VariableFont_wght.ttf │ ├── Mulish-Italic-VariableFont_wght.ttf │ └── RedHatMono-Italic-VariableFont_wght.ttf ├── pnpm-workspace.yaml ├── .github ├── FUNDING.yml └── workflows │ ├── code-ql.yml │ └── docker-image.yml ├── .dockerignore ├── public ├── favicon.png ├── screenshot.png ├── vuesualizer.png ├── Slack_Mark_Monochrome_White.svg └── Slack_Mark.svg ├── .gitignore ├── types ├── Sort.ts ├── Dm.ts ├── Channel.ts ├── User.ts ├── File.ts └── Message.ts ├── .env.sample ├── server ├── middleware │ └── auth.ts ├── api │ ├── workspaces │ │ └── index.delete.ts │ ├── messages │ │ ├── count.get.ts │ │ └── search.get.ts │ ├── groups │ │ ├── index.get.ts │ │ └── [name] │ │ │ └── index.get.ts │ ├── mpims │ │ ├── index.get.ts │ │ └── [id] │ │ │ └── index.get.ts │ ├── channels │ │ ├── index.get.ts │ │ └── [name] │ │ │ ├── index.get.ts │ │ │ └── messages.get.ts │ ├── dms │ │ ├── index.get.ts │ │ └── [id] │ │ │ └── index.get.ts │ ├── users │ │ └── index.get.ts │ ├── import │ │ ├── channel │ │ │ └── [channel] │ │ │ │ └── index.post.ts │ │ └── workspace │ │ │ └── index.post.ts │ └── files │ │ └── index.post.ts └── utils │ ├── mongo.ts │ └── normalizeMessages.ts ├── .eslintrc ├── components ├── base │ ├── AppVersion.vue │ ├── Sort.vue │ ├── Pagination.vue │ └── Select.vue ├── message │ ├── blocks │ │ ├── Broadcast.vue │ │ ├── Link.vue │ │ ├── Emoji.vue │ │ ├── RichTextSection.vue │ │ ├── RichText.vue │ │ ├── User.vue │ │ ├── RichTextQuote.vue │ │ ├── RichTextPreformatted.vue │ │ ├── Channel.vue │ │ ├── Text.vue │ │ └── RichTextList.vue │ ├── Skeleton.vue │ ├── Attachments.vue │ ├── Files.vue │ ├── Replies.vue │ ├── Results.vue │ ├── Block.vue │ ├── Reaction.vue │ ├── List.vue │ ├── Item.vue │ └── Search.vue ├── channel │ ├── Title.vue │ ├── List.vue │ ├── MultiSelect.vue │ └── Header.vue ├── stats │ ├── Users.vue │ ├── Channels.vue │ ├── Base.vue │ └── Messages.vue ├── user │ ├── Timezone.vue │ ├── Phone.vue │ ├── Email.vue │ ├── Name.vue │ ├── MultiSelect.vue │ └── Avatar.vue ├── nav │ ├── Token.vue │ ├── GithubLink.vue │ ├── SideDrawer.vue │ ├── Menu.vue │ ├── Header.vue │ ├── ThemeToggle.vue │ ├── Footer.vue │ └── LocaleChanger.vue ├── upload │ ├── Success.vue │ ├── ChannelSelect.vue │ ├── FileForm.vue │ ├── Worker.vue │ └── Stepper.vue ├── workspace │ ├── Delete.vue │ └── ConfirmDelete.vue └── files │ ├── Filter.vue │ └── DetailRow.vue ├── composables ├── states.ts ├── useToken.ts ├── useTsToDate.ts ├── useEmoji.ts ├── useZip.ts ├── useFlip.ts ├── useSearch.ts ├── useFile.ts ├── useUsers.ts ├── useMessages.ts ├── useChannels.ts └── useUpload.ts ├── plugins └── flip.ts ├── layouts ├── upload.vue └── default.vue ├── tsconfig.json ├── pages ├── upload.vue ├── workspace │ └── index.vue ├── index.vue ├── users │ ├── [id].vue │ └── index.vue ├── channels │ ├── [channel].vue │ └── index.vue └── files │ └── index.vue ├── .vscode └── settings.json ├── docker-compose.dev.yml ├── middleware └── auth.global.ts ├── Dockerfile ├── docker-compose.yml ├── renovate.json ├── LICENSE ├── i18n ├── i18n.config.ts └── locales │ ├── en.json │ └── de.json ├── tailwind.config.ts ├── nuxt.config.ts ├── app.vue ├── package.json └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | -------------------------------------------------------------------------------- /assets/css/index.css: -------------------------------------------------------------------------------- 1 | @import "./font.css"; 2 | @import "./transitions/index.css"; 3 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | onlyBuiltDependencies: 2 | - '@parcel/watcher' 3 | - esbuild 4 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: 4350pChris 4 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .git 3 | .env 4 | .gitignore 5 | Slack_Mark.svg 6 | screenshot.png 7 | -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4350pChris/slack-vuesualizer/HEAD/public/favicon.png -------------------------------------------------------------------------------- /public/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4350pChris/slack-vuesualizer/HEAD/public/screenshot.png -------------------------------------------------------------------------------- /public/vuesualizer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4350pChris/slack-vuesualizer/HEAD/public/vuesualizer.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log* 3 | .nuxt 4 | .nitro 5 | .cache 6 | .output 7 | .env 8 | dist 9 | .vercel 10 | .idea -------------------------------------------------------------------------------- /types/Sort.ts: -------------------------------------------------------------------------------- 1 | export enum Sortable { 2 | AtoZ = 'atoz', 3 | ZtoA = 'ztoa', 4 | Newest = 'newest', 5 | Oldest = 'oldest', 6 | } 7 | -------------------------------------------------------------------------------- /types/Dm.ts: -------------------------------------------------------------------------------- 1 | export interface Dm { 2 | _id: string 3 | id: string 4 | created: number 5 | members: string[] 6 | name: string 7 | } 8 | -------------------------------------------------------------------------------- /assets/fonts/Mulish-VariableFont_wght.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4350pChris/slack-vuesualizer/HEAD/assets/fonts/Mulish-VariableFont_wght.ttf -------------------------------------------------------------------------------- /assets/fonts/RedHatMono-VariableFont_wght.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4350pChris/slack-vuesualizer/HEAD/assets/fonts/RedHatMono-VariableFont_wght.ttf -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | NUXT_MONGODB_URI=mongodb://root:example@localhost:27017 2 | NUXT_PUBLIC_CANONICAL_HOST=http://localhost:3000 3 | NUXT_PUBLIC_DEMO_WORKSPACE_TOKEN= 4 | -------------------------------------------------------------------------------- /assets/fonts/Mulish-Italic-VariableFont_wght.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4350pChris/slack-vuesualizer/HEAD/assets/fonts/Mulish-Italic-VariableFont_wght.ttf -------------------------------------------------------------------------------- /assets/css/transitions/index.css: -------------------------------------------------------------------------------- 1 | @import "./fade.css"; 2 | @import "./slide-y.css"; 3 | @import "./slide-x.css"; 4 | @import "./page.css"; 5 | @import "./list.css"; 6 | -------------------------------------------------------------------------------- /assets/fonts/RedHatMono-Italic-VariableFont_wght.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4350pChris/slack-vuesualizer/HEAD/assets/fonts/RedHatMono-Italic-VariableFont_wght.ttf -------------------------------------------------------------------------------- /server/middleware/auth.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler((event) => { 2 | const mongouuid = getCookie(event, 'mongouuid') 3 | event.context.mongouuid = mongouuid 4 | }) 5 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@antfu", 3 | "rules": { 4 | "import/first": "off", 5 | "antfu/generic-spacing": "off", 6 | "antfu/top-level-function": "off" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /components/base/AppVersion.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 8 | -------------------------------------------------------------------------------- /composables/states.ts: -------------------------------------------------------------------------------- 1 | import type { Dm } from '~/types/Dm' 2 | import type { Channel } from '~~/types/Channel' 3 | import type { User } from '~~/types/User' 4 | 5 | export const useUsers = () => useState('users', () => []) 6 | -------------------------------------------------------------------------------- /server/api/workspaces/index.delete.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(async (event) => { 2 | const db = await mongo(event.context.mongouuid) 3 | 4 | await db.dropDatabase() 5 | 6 | event.node.res.statusCode = 204 7 | 8 | return '' 9 | }) 10 | -------------------------------------------------------------------------------- /plugins/flip.ts: -------------------------------------------------------------------------------- 1 | import { gsap } from 'gsap' 2 | import { Flip } from 'gsap/Flip' 3 | 4 | export default defineNuxtPlugin(() => { 5 | gsap.registerPlugin(Flip) 6 | 7 | return { 8 | provide: { 9 | gsap, 10 | Flip, 11 | } 12 | } 13 | }) 14 | -------------------------------------------------------------------------------- /layouts/upload.vue: -------------------------------------------------------------------------------- 1 | 11 | -------------------------------------------------------------------------------- /assets/css/transitions/list.css: -------------------------------------------------------------------------------- 1 | .list-move, /* apply transition to moving elements */ 2 | .list-enter-active, 3 | .list-leave-active { 4 | @apply transition-all ease-in-out; 5 | } 6 | 7 | .list-enter-from, 8 | .list-leave-to { 9 | @apply opacity-0 translate-x-20; 10 | } 11 | -------------------------------------------------------------------------------- /components/message/blocks/Broadcast.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://v3.nuxtjs.org/concepts/typescript 3 | "extends": "./.nuxt/tsconfig.json", 4 | "compilerOptions": { 5 | "types": ["unplugin-icons/types/vue"], 6 | "jsx": "preserve", 7 | "resolveJsonModule": true, 8 | "esModuleInterop": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /composables/useToken.ts: -------------------------------------------------------------------------------- 1 | export const useToken = () => useCookie('mongouuid') 2 | 3 | export const useShareLink = () => { 4 | const token = useToken() 5 | 6 | return computed( 7 | () => 8 | `${window.location.protocol}//${window.location.host}/?token=${token.value}`, 9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /server/api/messages/count.get.ts: -------------------------------------------------------------------------------- 1 | import { mongo } from '~/server/utils/mongo' 2 | 3 | export default defineEventHandler(async (event) => { 4 | const db = await mongo(event.context.mongouuid) 5 | 6 | const count = await db.collection('messages').countDocuments() 7 | 8 | return count 9 | }) 10 | -------------------------------------------------------------------------------- /assets/css/transitions/slide-x.css: -------------------------------------------------------------------------------- 1 | .slide-x-enter-from, 2 | .slide-x-leave-to { 3 | @apply transition-opacity ease-in-out opacity-0 -translate-x-2; 4 | } 5 | 6 | .slide-x-enter-active { 7 | @apply transition ease-out; 8 | } 9 | 10 | .slide-x-leave-active { 11 | @apply transition ease-in; 12 | } 13 | -------------------------------------------------------------------------------- /assets/css/transitions/slide-y.css: -------------------------------------------------------------------------------- 1 | .slide-y-enter-from, 2 | .slide-y-leave-to { 3 | @apply transition-opacity ease-in-out opacity-0 -translate-y-2; 4 | } 5 | 6 | .slide-y-enter-active { 7 | @apply transition ease-out; 8 | } 9 | 10 | .slide-y-leave-active { 11 | @apply transition ease-in; 12 | } 13 | -------------------------------------------------------------------------------- /composables/useTsToDate.ts: -------------------------------------------------------------------------------- 1 | export default () => (ts: string | number | undefined | null) => { 2 | 3 | if (!ts) return undefined 4 | 5 | const timestampInSeconds 6 | = typeof ts === 'number' ? ts : Number.parseInt(ts.split('.')[0]) 7 | 8 | return new Date(timestampInSeconds * 1000) 9 | } 10 | -------------------------------------------------------------------------------- /components/channel/Title.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 16 | -------------------------------------------------------------------------------- /types/Channel.ts: -------------------------------------------------------------------------------- 1 | export interface Topic { 2 | value: string 3 | creator: string 4 | } 5 | 6 | export interface Channel { 7 | _id: string 8 | id: string 9 | name: string 10 | created: number 11 | creator: string 12 | members: string[] 13 | topic: Topic 14 | purpose: Topic 15 | is_general: boolean 16 | } 17 | -------------------------------------------------------------------------------- /assets/css/transitions/fade.css: -------------------------------------------------------------------------------- 1 | .fade-enter-to, 2 | .fade-leave-from { 3 | @apply opacity-100; 4 | } 5 | .fade-enter-from, 6 | .fade-leave-to { 7 | @apply opacity-0; 8 | } 9 | 10 | .fade-enter-active { 11 | @apply transition-opacity ease-out; 12 | } 13 | 14 | .fade-leave-active { 15 | @apply transition-opacity ease-in; 16 | } 17 | -------------------------------------------------------------------------------- /components/stats/Users.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 15 | -------------------------------------------------------------------------------- /pages/upload.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /components/stats/Channels.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 15 | -------------------------------------------------------------------------------- /pages/workspace/index.vue: -------------------------------------------------------------------------------- 1 | 13 | -------------------------------------------------------------------------------- /components/message/blocks/Link.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 17 | -------------------------------------------------------------------------------- /components/message/blocks/Emoji.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 14 | -------------------------------------------------------------------------------- /components/message/blocks/RichTextSection.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 16 | -------------------------------------------------------------------------------- /components/user/Timezone.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 17 | -------------------------------------------------------------------------------- /composables/useEmoji.ts: -------------------------------------------------------------------------------- 1 | import EmojiConvertor from 'emoji-js' 2 | import type { MaybeRef } from '@vueuse/core' 3 | 4 | export default (name: MaybeRef) => { 5 | const emoji = new EmojiConvertor() 6 | emoji.replace_mode = 'unified' 7 | 8 | const emojiUnicode = computed(() => { 9 | return emoji.replace_colons(`:${unref(name)}:`) 10 | }) 11 | 12 | return { emojiUnicode } 13 | } 14 | -------------------------------------------------------------------------------- /components/message/blocks/RichText.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 16 | -------------------------------------------------------------------------------- /components/stats/Base.vue: -------------------------------------------------------------------------------- 1 | 15 | -------------------------------------------------------------------------------- /server/api/groups/index.get.ts: -------------------------------------------------------------------------------- 1 | import { mongo } from '~/server/utils/mongo' 2 | import type { Channel } from '~~/types/Channel' 3 | 4 | export default defineEventHandler(async (event) => { 5 | const db = await mongo(event.context.mongouuid) 6 | const channels = await db 7 | .collection('groups') 8 | .find() 9 | .sort({ name: 1 }) 10 | .toArray() 11 | 12 | return channels 13 | }) 14 | -------------------------------------------------------------------------------- /server/api/mpims/index.get.ts: -------------------------------------------------------------------------------- 1 | import { mongo } from '~/server/utils/mongo' 2 | import type { Channel } from '~~/types/Channel' 3 | 4 | export default defineEventHandler(async (event) => { 5 | const db = await mongo(event.context.mongouuid) 6 | const channels = await db 7 | .collection('mpims') 8 | .find() 9 | .sort({ name: 1 }) 10 | .toArray() 11 | 12 | return channels 13 | }) 14 | -------------------------------------------------------------------------------- /components/stats/Messages.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 17 | -------------------------------------------------------------------------------- /server/api/channels/index.get.ts: -------------------------------------------------------------------------------- 1 | import { mongo } from '~/server/utils/mongo' 2 | import type { Channel } from '~~/types/Channel' 3 | 4 | export default defineEventHandler(async (event) => { 5 | const db = await mongo(event.context.mongouuid) 6 | const channels = await db 7 | .collection('channels') 8 | .find() 9 | .sort({ name: 1 }) 10 | .toArray() 11 | 12 | return channels 13 | }) 14 | -------------------------------------------------------------------------------- /components/message/blocks/User.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 16 | -------------------------------------------------------------------------------- /server/api/dms/index.get.ts: -------------------------------------------------------------------------------- 1 | import { mongo } from '~/server/utils/mongo' 2 | import type { Dm } from '~~/types/Dm' 3 | 4 | export default defineEventHandler(async (event) => { 5 | const db = await mongo(event.context.mongouuid) 6 | const dms = await db 7 | .collection('dms') 8 | .find() 9 | .map(dm => ({ 10 | ...dm, 11 | name: dm.id 12 | })) 13 | .toArray() 14 | 15 | return dms 16 | }) 17 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "prettier.enable": false, 3 | "editor.formatOnSave": false, 4 | "editor.codeActionsOnSave": { 5 | "source.fixAll.eslint": "explicit", 6 | "source.organizeImports": "never" 7 | }, 8 | "tailwindCSS.experimental.configFile": ".nuxt/tailwind.config.cjs", 9 | "i18n-ally.localesPaths": [ 10 | "locales", 11 | "server/api/messages" 12 | ], 13 | "i18n-ally.keystyle": "nested" 14 | } 15 | -------------------------------------------------------------------------------- /components/user/Phone.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 18 | -------------------------------------------------------------------------------- /server/api/users/index.get.ts: -------------------------------------------------------------------------------- 1 | import { mongo } from '~/server/utils/mongo' 2 | import type { User } from '~~/types/User' 3 | 4 | export default defineEventHandler(async (event) => { 5 | const db = await mongo(event.context.mongouuid) 6 | const users = await db 7 | .collection('users') 8 | .find() 9 | .sort({ 'is_bot': 1, 'profile.display_name': 1, 'profile.real_name': 1 }) 10 | .toArray() 11 | return users 12 | }) 13 | -------------------------------------------------------------------------------- /components/message/blocks/RichTextQuote.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 18 | -------------------------------------------------------------------------------- /components/message/blocks/RichTextPreformatted.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 18 | -------------------------------------------------------------------------------- /components/user/Email.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 19 | -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | services: 2 | mongo: 3 | image: mongo:7 4 | restart: unless-stopped 5 | ports: 6 | - 27017:27017 7 | environment: 8 | MONGO_INITDB_ROOT_USERNAME: root 9 | MONGO_INITDB_ROOT_PASSWORD: example 10 | MONGO_INITDB_DATABASE: slack 11 | 12 | mongo-express: 13 | image: mongo-express 14 | restart: unless-stopped 15 | ports: 16 | - 8081:8081 17 | environment: 18 | ME_CONFIG_MONGODB_URL: 'mongodb://root:example@mongo:27017/' 19 | -------------------------------------------------------------------------------- /server/api/dms/[id]/index.get.ts: -------------------------------------------------------------------------------- 1 | import { mongo } from '~/server/utils/mongo' 2 | import type { Dm } from '~/types/Dm' 3 | 4 | export default defineEventHandler(async (event) => { 5 | const _id = decodeURIComponent(event.context.params!.id) 6 | const db = await mongo(event.context.mongouuid) 7 | const dm = await db.collection('dms').findOne({ _id }) 8 | 9 | if (!dm) 10 | throw createError({ statusCode: 404, statusMessage: 'Dm not found' }) 11 | 12 | return { ...dm, name: dm.id } 13 | }) 14 | -------------------------------------------------------------------------------- /components/message/Skeleton.vue: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /components/message/blocks/Channel.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 18 | -------------------------------------------------------------------------------- /components/message/blocks/Text.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 19 | -------------------------------------------------------------------------------- /server/api/import/channel/[channel]/index.post.ts: -------------------------------------------------------------------------------- 1 | import { mongo } from '~~/server/utils/mongo' 2 | 3 | export default defineEventHandler(async (event) => { 4 | const channel = decodeURIComponent(event.context.params!.channel) 5 | const db = await mongo(event.context.mongouuid) 6 | const { data } = await readBody<{ data: any[] }>(event) 7 | 8 | await db 9 | .collection('messages') 10 | .insertMany(data.map(entry => ({ ...entry, channel }))) 11 | 12 | event.node.res.statusCode = 201 13 | return 'ok' 14 | }) 15 | -------------------------------------------------------------------------------- /components/user/Name.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 18 | -------------------------------------------------------------------------------- /server/api/groups/[name]/index.get.ts: -------------------------------------------------------------------------------- 1 | import { mongo } from '~/server/utils/mongo' 2 | import type { Channel } from '~/types/Channel' 3 | 4 | export default defineEventHandler(async (event) => { 5 | const name = decodeURIComponent(event.context.params!.name) 6 | const db = await mongo(event.context.mongouuid) 7 | const channel = await db.collection('groups').findOne({ name }) 8 | 9 | if (!channel) 10 | throw createError({ statusCode: 404, statusMessage: 'Group not found' }) 11 | 12 | return channel 13 | }) 14 | -------------------------------------------------------------------------------- /server/api/channels/[name]/index.get.ts: -------------------------------------------------------------------------------- 1 | import { mongo } from '~/server/utils/mongo' 2 | import type { Channel } from '~/types/Channel' 3 | 4 | export default defineEventHandler(async (event) => { 5 | const name = decodeURIComponent(event.context.params!.name) 6 | const db = await mongo(event.context.mongouuid) 7 | const channel = await db.collection('channels').findOne({ name }) 8 | 9 | if (!channel) 10 | throw createError({ statusCode: 404, statusMessage: 'Channel not found' }) 11 | 12 | return channel 13 | }) 14 | -------------------------------------------------------------------------------- /server/api/mpims/[id]/index.get.ts: -------------------------------------------------------------------------------- 1 | import { mongo } from '~/server/utils/mongo' 2 | import type { Channel } from '~/types/Channel' 3 | 4 | export default defineEventHandler(async (event) => { 5 | const id = decodeURIComponent(event.context.params!.id) 6 | const db = await mongo(event.context.mongouuid) 7 | const channel = await db.collection('mpims').findOne({ id }) 8 | 9 | if (!channel) 10 | throw createError({ statusCode: 404, statusMessage: 'Private messageing group not found' }) 11 | 12 | return channel 13 | }) 14 | -------------------------------------------------------------------------------- /assets/css/transitions/page.css: -------------------------------------------------------------------------------- 1 | .page-enter-active { 2 | @apply transition-all duration-100 ease-out; 3 | } 4 | .page-leave-active { 5 | @apply transition-all duration-200 ease-in; 6 | } 7 | .page-enter-from, 8 | .page-leave-to { 9 | @apply translate-y-2 opacity-0; 10 | } 11 | 12 | .layout-enter-active { 13 | @apply transition-all duration-100 ease-out; 14 | } 15 | .layout-leave-active { 16 | @apply transition-all duration-200 ease-in; 17 | } 18 | .layout-enter-from, 19 | .layout-leave-to { 20 | @apply translate-y-4 opacity-0; 21 | } 22 | -------------------------------------------------------------------------------- /server/utils/mongo.ts: -------------------------------------------------------------------------------- 1 | import { MongoClient } from 'mongodb' 2 | 3 | let _fullClient: MongoClient | null = null 4 | 5 | export const fullClient = async () => { 6 | if (_fullClient === null) { 7 | const uri = useRuntimeConfig().mongodbUri 8 | try { 9 | _fullClient = await MongoClient.connect(uri) 10 | } 11 | catch (e) { 12 | console.error('Failed to connect to mongo', e) 13 | throw e 14 | } 15 | } 16 | return _fullClient 17 | } 18 | 19 | export const mongo = async (dbUuid: string) => { 20 | return fullClient().then(c => c.db(dbUuid)) 21 | } 22 | -------------------------------------------------------------------------------- /server/api/channels/[name]/messages.get.ts: -------------------------------------------------------------------------------- 1 | import { mongo } from '~/server/utils/mongo' 2 | import normalizeMessages from '~/server/utils/normalizeMessages' 3 | import type { Message } from '~/types/Message' 4 | 5 | export default defineEventHandler(async (event) => { 6 | const channel = decodeURIComponent(event.context.params!.name) 7 | const db = await mongo(event.context.mongouuid) 8 | const messages = await db 9 | .collection('messages') 10 | .find({ channel }) 11 | .sort({ ts: 1 }) 12 | .toArray() 13 | 14 | return normalizeMessages(messages) 15 | }) 16 | -------------------------------------------------------------------------------- /middleware/auth.global.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtRouteMiddleware((to, _) => { 2 | const urlToken = to.query.token 3 | const token = useCookie('mongouuid') 4 | const localePath = useLocalePath() 5 | const localeRoute = useLocaleRoute() 6 | 7 | if (typeof urlToken === 'string') 8 | token.value = urlToken 9 | 10 | 11 | if (token.value && to.path === localePath('/')) 12 | return navigateTo(localeRoute({ name: 'workspace', query: { token: token.value.toString() }} )) 13 | 14 | if (!token.value && to.meta.layout !== 'upload') 15 | return navigateTo(localeRoute({ name: 'upload' })) 16 | }) 17 | -------------------------------------------------------------------------------- /components/nav/Token.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 20 | -------------------------------------------------------------------------------- /components/message/blocks/RichTextList.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 22 | -------------------------------------------------------------------------------- /components/nav/GithubLink.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 26 | -------------------------------------------------------------------------------- /types/User.ts: -------------------------------------------------------------------------------- 1 | export interface Profile { 2 | title: string 3 | real_name: string 4 | display_name: string 5 | status_text: string 6 | status_emoji: string 7 | iamge_original: string 8 | email: string 9 | phone: string 10 | first_name: string 11 | last_name: string 12 | image_48: string 13 | image_72: string 14 | image_192: string 15 | image_512: string 16 | image_1024: string 17 | } 18 | 19 | export interface User { 20 | id: string 21 | name: string 22 | real_name: string 23 | profile: Profile 24 | is_admin: boolean 25 | is_owner: boolean 26 | deleted: boolean 27 | is_bot: boolean 28 | color: string 29 | tz: string 30 | } 31 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build stage 2 | FROM node:22-alpine AS builder 3 | 4 | WORKDIR /app 5 | 6 | RUN corepack enable 7 | 8 | COPY package.json pnpm-lock.yaml .npmrc ./ 9 | 10 | RUN pnpm i 11 | 12 | COPY . ./ 13 | RUN pnpm run build 14 | 15 | # Production stage 16 | FROM node:22-alpine AS production 17 | 18 | ARG MODE=production 19 | ARG PORT=3000 20 | ARG VERSION=latest 21 | ARG BUILD_DATE=latest 22 | 23 | ENV NODE_ENV=${MODE} 24 | ENV NUXT_HOST=0.0.0.0 25 | ENV NUXT_PORT=${PORT} 26 | ENV NUXT_PUBLIC_VERSION=${VERSION} 27 | ENV NUXT_PUBLIC_BUILD_DATE=${BUILD_DATE} 28 | 29 | WORKDIR /app 30 | 31 | COPY --from=builder /app/.output ./ 32 | 33 | EXPOSE ${PORT} 34 | 35 | CMD ["node", "/app/server/index.mjs"] 36 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | volumes: 2 | mongo-data: 3 | 4 | services: 5 | app: 6 | image: chris5896/slack-vuesualizer 7 | restart: unless-stopped 8 | ports: 9 | - '3000:3000' 10 | environment: 11 | NUXT_MONGODB_URI: 'mongodb://root:example@mongo:27017' 12 | 13 | mongo: 14 | image: mongo:7 15 | restart: unless-stopped 16 | volumes: 17 | - mongo-data:/data/db 18 | environment: 19 | MONGO_INITDB_ROOT_USERNAME: root 20 | MONGO_INITDB_ROOT_PASSWORD: example 21 | MONGO_INITDB_DATABASE: slack 22 | 23 | mongo-express: 24 | image: mongo-express 25 | restart: unless-stopped 26 | environment: 27 | ME_CONFIG_MONGODB_URL: 'mongodb://root:example@mongo:27017/' 28 | -------------------------------------------------------------------------------- /composables/useZip.ts: -------------------------------------------------------------------------------- 1 | import * as zip from '@zip.js/zip.js' 2 | 3 | export const useZip = () => { 4 | const readZip = (file: File) => { 5 | return new zip.ZipReader(new zip.BlobReader(file)).getEntries({ 6 | filenameEncoding: 'utf-8', 7 | }) 8 | } 9 | 10 | const parseData = async (entries: zip.Entry[]): Promise => { 11 | const parsedData: any[] = [] 12 | for (const entry of entries) { 13 | const writer = new zip.TextWriter('utf-8') 14 | const data = await entry.getData?.(writer) 15 | 16 | // data should always be an array of objects 17 | if (data) 18 | parsedData.push(...JSON.parse(data)) 19 | } 20 | return parsedData 21 | } 22 | 23 | return { 24 | readZip, 25 | parseData, 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /components/base/Sort.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 36 | -------------------------------------------------------------------------------- /components/upload/Success.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 28 | -------------------------------------------------------------------------------- /assets/css/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer components { 6 | .fancy-link { 7 | @apply transition text-blue-600 hover:text-sky-500 visited:text-purple-600 visited:hover:text-purple-800 dark:text-blue-300 dark:hover:text-blue-500 dark:visited:text-purple-400 dark:visited:hover:text-purple-500; 8 | } 9 | } 10 | 11 | @layer utilities { 12 | .skeleton-loader { 13 | @apply block bg-repeat-y bg-slate-200/80 dark:bg-slate-700 bg-gradient-to-r from-slate-100/0 via-slate-100/50 to-slate-100/80 dark:from-slate-500/0 dark:via-slate-500/50 dark:to-slate-500/80 animate-pulse; 14 | background-size: 50px 500px; 15 | background-position: 0 0; 16 | animation: shine 1s infinite; 17 | } 18 | 19 | @keyframes shine { 20 | to { 21 | background-position: 100% 0; 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /layouts/default.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 31 | -------------------------------------------------------------------------------- /assets/css/font.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Mulish'; 3 | font-style: normal; 4 | src: url('../fonts/Mulish-VariableFont_wght.ttf') format('truetype'); 5 | font-weight: 1 999; 6 | font-display: swap; 7 | } 8 | 9 | @font-face { 10 | font-family: 'Mulish'; 11 | font-style: italic; 12 | src: url('../fonts/Mulish-Italic-VariableFont_wght.ttf') format('truetype'); 13 | font-weight: 1 999; 14 | font-display: swap; 15 | } 16 | 17 | @font-face { 18 | font-family: 'Red Hat Mono'; 19 | font-style: normal; 20 | src: url('../fonts/RedHatMono-VariableFont_wght.ttf') format('truetype'); 21 | font-weight: 1 999; 22 | font-display: swap; 23 | } 24 | 25 | @font-face { 26 | font-family: 'Red Hat Mono'; 27 | font-style: italic; 28 | src: url('../fonts/RedHatMono-VariableFont_wght.ttf') format('truetype'); 29 | font-weight: 1 999; 30 | font-display: swap; 31 | } 32 | -------------------------------------------------------------------------------- /components/nav/SideDrawer.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 29 | -------------------------------------------------------------------------------- /components/message/Attachments.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 36 | -------------------------------------------------------------------------------- /components/channel/List.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 25 | 26 | 31 | -------------------------------------------------------------------------------- /server/api/messages/search.get.ts: -------------------------------------------------------------------------------- 1 | import type { Filter } from 'mongodb' 2 | import { mongo } from '~/server/utils/mongo' 3 | import type { Message } from '~/types/Message' 4 | 5 | export default defineEventHandler(async (event) => { 6 | const { query, channel } = getQuery(event) 7 | 8 | if (!query) 9 | throw createError({ statusCode: 400, statusMessage: 'Missing query' }) 10 | 11 | const db = await mongo(event.context.mongouuid) 12 | 13 | const filter: Filter = { 14 | $and: [{ $text: { $search: query.toString() } }], 15 | } 16 | 17 | if (channel) 18 | filter.$and!.push({ channel: decodeURIComponent(channel.toString()) }) 19 | 20 | const messages = await db 21 | .collection('messages') 22 | .find(filter) 23 | .project({ score: { $meta: 'textScore' } }) 24 | .limit(30) 25 | .sort({ score: { $meta: 'textScore' } }) 26 | .toArray() 27 | 28 | return messages 29 | }) 30 | -------------------------------------------------------------------------------- /components/message/Files.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 38 | -------------------------------------------------------------------------------- /components/workspace/Delete.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 29 | -------------------------------------------------------------------------------- /components/workspace/ConfirmDelete.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 28 | -------------------------------------------------------------------------------- /components/nav/Menu.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 36 | -------------------------------------------------------------------------------- /types/File.ts: -------------------------------------------------------------------------------- 1 | export interface HiddenFile { 2 | id: string 3 | mode: 'hidden_by_limit' 4 | } 5 | 6 | export interface ShownFile { 7 | id: string 8 | mode: string 9 | name: string 10 | title: string 11 | mimetype: string 12 | user: string 13 | url_private: string 14 | permalink: string 15 | timestamp: number 16 | pretty_type: string 17 | filetype: string 18 | size: number 19 | } 20 | 21 | export type PdfFile = ShownFile & { 22 | filetype: 'pdf' 23 | thumb_pdf: string 24 | } 25 | 26 | export type DocFile = ShownFile & { 27 | filetype: 'docx' 28 | converted_pdf: string 29 | thumb_pdf: string 30 | } 31 | 32 | export type ImageFile = ShownFile & { 33 | thumb_64: string 34 | thumb_80: string 35 | } 36 | 37 | export type VideoFile = ShownFile & { 38 | thumb_video: string 39 | mp4: string 40 | } 41 | 42 | export type File = HiddenFile | ShownFile 43 | 44 | export interface SearchResult { 45 | _id: string 46 | channel: string 47 | files: ShownFile 48 | } 49 | -------------------------------------------------------------------------------- /components/user/MultiSelect.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 39 | -------------------------------------------------------------------------------- /components/channel/MultiSelect.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 39 | -------------------------------------------------------------------------------- /components/nav/Header.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 33 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:base"], 4 | "packageRules": [ 5 | { 6 | "matchUpdateTypes": ["minor", "patch"], 7 | "matchCurrentVersion": "!/^0/", 8 | "automerge": true 9 | }, 10 | { 11 | "matchDepTypes": ["devDependencies"], 12 | "matchPackagePatterns": ["lint", "prettier"], 13 | "automerge": true 14 | }, 15 | { 16 | "groupName": "icons", 17 | "matchPackageNames": ["unplugin-icons"], 18 | "matchPackagePrefixes": ["@iconify-json/"] 19 | }, 20 | { 21 | "groupName": "definitelyTyped", 22 | "matchPackagePrefixes": ["@types/"] 23 | }, 24 | { 25 | "groupName": "nuxt plugins", 26 | "matchPackageNames": ["@nuxtjs/"], 27 | "matchPackagePrefixes": ["@nuxtjs/"] 28 | }, 29 | { 30 | "groupName": "headlessui", 31 | "matchPackagePrefixes": ["@headlessui/"] 32 | }, 33 | { 34 | "groupName": "vueuse", 35 | "matchPackagePrefixes": ["@vueuse/"] 36 | } 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /components/message/Replies.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 35 | -------------------------------------------------------------------------------- /components/user/Avatar.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 37 | -------------------------------------------------------------------------------- /components/message/Results.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Chris-Robin Ennen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /server/utils/normalizeMessages.ts: -------------------------------------------------------------------------------- 1 | import { assert } from '@vueuse/core' 2 | import type { ApiMessage, Message } from '~/types/Message' 3 | 4 | export default function (messages: ApiMessage[]): Message[] { 5 | const cpy: ApiMessage[] = [] 6 | const seen = new Set() 7 | 8 | messages.forEach((m) => { 9 | if (seen.has(m.ts)) { 10 | // this message is a reply, we already added it 11 | return 12 | } 13 | 14 | cpy.push(m) 15 | seen.add(m.ts) 16 | 17 | // replies is empty or undefined - we can just leave it as is, replies will be cleaned up later 18 | if (!m.replies) { 19 | return 20 | } 21 | 22 | // for each reply, move it to the correct position in cpy 23 | m.replies.forEach((reply, i) => { 24 | const replyMessage = messages.find( 25 | m => m.ts === reply.ts && m.user === reply.user, 26 | ) 27 | // should not happen... 28 | assert(reply !== undefined) 29 | // first remove from cpy 30 | cpy.push({ 31 | ...replyMessage, 32 | reply: true, 33 | last_reply: i === m.replies!.length - 1, 34 | } as Message) 35 | seen.add(reply.ts) 36 | }) 37 | }) 38 | return cpy 39 | } 40 | -------------------------------------------------------------------------------- /components/nav/ThemeToggle.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 43 | -------------------------------------------------------------------------------- /components/nav/Footer.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 41 | -------------------------------------------------------------------------------- /i18n/i18n.config.ts: -------------------------------------------------------------------------------- 1 | import en from './locales/en.json'; 2 | import de from './locales/de.json'; 3 | 4 | export default defineI18nConfig(() => ({ 5 | legacy: false, 6 | locale: 'en', 7 | fallbackLocale: 'de', 8 | datetimeFormats: { 9 | de: { 10 | short: { 11 | year: 'numeric', 12 | month: 'short', 13 | day: 'numeric', 14 | }, 15 | long: { 16 | year: 'numeric', 17 | month: 'short', 18 | day: 'numeric', 19 | weekday: 'short', 20 | hour: 'numeric', 21 | minute: 'numeric', 22 | }, 23 | timeOfDay: { 24 | hour: 'numeric', 25 | minute: 'numeric', 26 | }, 27 | }, 28 | en: { 29 | short: { 30 | year: 'numeric', 31 | month: 'short', 32 | day: 'numeric', 33 | }, 34 | long: { 35 | year: 'numeric', 36 | month: 'short', 37 | day: 'numeric', 38 | weekday: 'short', 39 | hour: 'numeric', 40 | minute: 'numeric', 41 | hour12: true, 42 | }, 43 | timeOfDay: { 44 | hour: 'numeric', 45 | minute: 'numeric', 46 | hour12: true, 47 | }, 48 | }, 49 | }, 50 | 51 | messages: { 52 | en, 53 | de 54 | } 55 | })) 56 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss' 2 | import defaultTheme from 'tailwindcss/defaultTheme' 3 | import tailwindTypography from '@tailwindcss/typography' 4 | import daisyui from 'daisyui' 5 | import headlessui from '@headlessui/tailwindcss' 6 | 7 | export default > { 8 | darkMode: ['class', '[data-theme=\'business\']'], 9 | content: [ 10 | './components/**/*.{js,vue,ts}', 11 | './layouts/**/*.vue', 12 | './pages/**/*.vue', 13 | './plugins/**/*.{js,ts}', 14 | 'app.vue', 15 | ], 16 | future: { 17 | hoverOnlyWhenSupported: true, 18 | }, 19 | safelist: ['active'], 20 | theme: { 21 | extend: { 22 | fontFamily: { 23 | sans: ['Mulish', ...defaultTheme.fontFamily.sans], 24 | mono: ['\'Red Hat Mono\'', ...defaultTheme.fontFamily.mono], 25 | }, 26 | animation: { 27 | blink: 'blink 2s cubic-bezier(0.4, 0, 0.6, 1)', 28 | }, 29 | keyframes: { 30 | blink: { 31 | '50%': { 32 | opacity: '0.5', 33 | }, 34 | }, 35 | }, 36 | }, 37 | }, 38 | plugins: [ 39 | tailwindTypography, 40 | daisyui, 41 | headlessui, 42 | ], 43 | daisyui: { 44 | themes: ['fantasy', 'business'], 45 | darkTheme: 'business', 46 | }, 47 | } 48 | -------------------------------------------------------------------------------- /composables/useFlip.ts: -------------------------------------------------------------------------------- 1 | import type { RouteLocationNormalized } from '#vue-router' 2 | 3 | type PathMatcher = (to: RouteLocationNormalized, from: RouteLocationNormalized) => boolean 4 | 5 | export function useFlip() { 6 | const { $Flip } = useNuxtApp() 7 | 8 | const flipState = useState('flip-state', () => null) 9 | 10 | function capture(...args: Parameters): void { 11 | flipState.value = $Flip.getState(...args) 12 | } 13 | 14 | function flip(targets: Flip.FromToVars["targets"]): void { 15 | if (!flipState.value) return 16 | 17 | $Flip.from(flipState.value, { 18 | targets, 19 | ease: "power1.inOut", 20 | }) 21 | } 22 | 23 | const registerAutoFlip = (pathMatcher: PathMatcher, captureArgs?: Parameters, flipArgs?: Parameters) => { 24 | onBeforeRouteLeave((to, from, next) => { 25 | const result = pathMatcher(to, from) 26 | if (result) { 27 | capture(captureArgs?.[0] ?? '[data-flip-id]', captureArgs?.[1]) 28 | } 29 | next() 30 | }) 31 | 32 | onMounted(() => { 33 | flip(flipArgs?.[0] ?? '[data-flip-id]') 34 | flipState.value = null 35 | }) 36 | } 37 | 38 | return { 39 | capture, 40 | flip, 41 | registerAutoFlip, 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /composables/useSearch.ts: -------------------------------------------------------------------------------- 1 | import type { Message } from '~/types/Message' 2 | 3 | export function useSearch(currentChannel: Ref) { 4 | const searching = ref(false) 5 | const results = ref([]) 6 | const query = ref('') 7 | const allChannels = ref(false) 8 | 9 | const search = useDebounceFn(async () => { 10 | const queryParams: { query: string; channel?: string | string[] } = { 11 | query: query.value, 12 | } 13 | 14 | if (!allChannels.value && currentChannel.value) 15 | queryParams.channel = currentChannel.value 16 | 17 | try { 18 | results.value = await $fetch('/api/messages/search', { 19 | query: queryParams, 20 | headers: useRequestHeaders(['cookie']), 21 | }) 22 | } 23 | catch (e) { 24 | console.error(e) 25 | } 26 | searching.value = false 27 | }, 500) 28 | 29 | watch([query, allChannels], () => { 30 | if (!query.value) { 31 | results.value = [] 32 | return 33 | } 34 | searching.value = true 35 | return search() 36 | }) 37 | 38 | whenever( 39 | () => !currentChannel.value, 40 | () => { 41 | allChannels.value = true 42 | }, 43 | { immediate: true }, 44 | ) 45 | 46 | return { allChannels, query, results, searching: readonly(searching), search } 47 | } 48 | -------------------------------------------------------------------------------- /components/message/Block.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 39 | -------------------------------------------------------------------------------- /nuxt.config.ts: -------------------------------------------------------------------------------- 1 | // https://v3.nuxtjs.org/api/configuration/nuxt.config 2 | export default defineNuxtConfig({ 3 | build: { 4 | transpile: ['@vuepic/vue-datepicker'], 5 | }, 6 | 7 | css: ['assets/css/index.css'], 8 | 9 | runtimeConfig: { 10 | mongodbUri: '', 11 | public: { 12 | demoWorkspaceToken: '', 13 | canonicalHost: '', 14 | version: 'latest', 15 | buildDate: 'today', 16 | }, 17 | }, 18 | 19 | plugins: [], 20 | 21 | modules: [[ 22 | 'unplugin-icons/nuxt', 23 | { 24 | autoInstall: true, 25 | }, 26 | ], '@vueuse/nuxt', '@nuxtjs/tailwindcss', '@nuxtjs/color-mode', '@nuxtjs/i18n', '@nuxtjs/robots', '@nuxt/icon'], 27 | 28 | colorMode: { 29 | preference: 'system', 30 | dataValue: 'theme', 31 | classSuffix: '', 32 | }, 33 | 34 | router: { 35 | options: { 36 | linkExactActiveClass: 'active', 37 | }, 38 | }, 39 | 40 | i18n: { 41 | bundle: { 42 | optimizeTranslationDirective: false, 43 | }, 44 | locales: [ 45 | { code: 'en', name: 'English', file: 'en.json' }, 46 | { code: 'de', name: 'Deutsch', file: 'de.json' }, 47 | ], 48 | defaultLocale: 'en', 49 | strategy: 'prefix', 50 | }, 51 | 52 | devtools: { 53 | enabled: true, 54 | }, 55 | 56 | compatibilityDate: '2024-07-29', 57 | }) 58 | -------------------------------------------------------------------------------- /composables/useFile.ts: -------------------------------------------------------------------------------- 1 | import { filesize } from 'filesize' 2 | import { useI18n } from 'vue-i18n' 3 | import type { DocFile, ImageFile, PdfFile, ShownFile, VideoFile } from '~/types/File' 4 | 5 | export const useFile = (file: Ref) => { 6 | const tsToDate = useTsToDate() 7 | 8 | const timestamp = computed(() => tsToDate(file.value.timestamp)) 9 | 10 | function fileHasThumbPdf(f: ShownFile): f is PdfFile | DocFile { 11 | return 'thumb_pdf' in f 12 | } 13 | 14 | function fileHasThumb80(f: ShownFile): f is ImageFile { 15 | return 'thumb_80' in f 16 | } 17 | 18 | function fileHasThumbVideo(f: ShownFile): f is VideoFile { 19 | return 'thumb_video' in f 20 | } 21 | 22 | function isAudioFile(f: ShownFile) { 23 | return f.mimetype.startsWith('audio') 24 | } 25 | 26 | const { locale } = useI18n() 27 | 28 | const size = computed(() => 29 | filesize(file.value.size, { locale: locale.value }), 30 | ) 31 | 32 | const previewImage = computed(() => { 33 | const f = unref(file) 34 | if (fileHasThumbPdf(f)) 35 | return f.thumb_pdf 36 | else if (fileHasThumb80(f)) 37 | return f.thumb_80 38 | else if (fileHasThumbVideo(f)) 39 | return f.thumb_video 40 | }) 41 | 42 | return { 43 | timestamp, 44 | previewImage, 45 | size, 46 | isAudioFile, 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /components/base/Pagination.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 49 | -------------------------------------------------------------------------------- /components/files/Filter.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 44 | -------------------------------------------------------------------------------- /components/message/Reaction.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 39 | -------------------------------------------------------------------------------- /pages/index.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 37 | -------------------------------------------------------------------------------- /components/upload/ChannelSelect.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 51 | -------------------------------------------------------------------------------- /server/api/import/workspace/index.post.ts: -------------------------------------------------------------------------------- 1 | import { randomUUID } from 'node:crypto' 2 | import type { Db } from 'mongodb' 3 | import { mongo } from '~/server/utils/mongo' 4 | import type { ApiMessage } from '~~/types/Message' 5 | 6 | interface DataIn { 7 | name: string 8 | data: any[] 9 | } 10 | 11 | const createDb = async (db: Db) => { 12 | const msgCol = db.collection('messages') 13 | await msgCol.createIndex({ text: 'text' }, { default_language: 'german', language_override: 'language_override' }) 14 | await msgCol.createIndex({ channel: 1 }) 15 | await msgCol.createIndex({ user: 1, ts: 1 }) 16 | } 17 | 18 | export default defineEventHandler(async (event) => { 19 | // prepare by creating db and indices 20 | const uuid = randomUUID() 21 | const db = await mongo(uuid) 22 | 23 | try { 24 | await createDb(db) 25 | } catch (e) { 26 | console.error('Error creating database:', e) 27 | // collections are full 28 | throw createError({ 29 | statusCode: 409, 30 | statusMessage: 'Database is full', 31 | cause: e, 32 | }) 33 | } 34 | 35 | const { data } = await readBody<{ data: DataIn[] }>(event) 36 | 37 | await Promise.all( 38 | data.map(({ name, data }) => data.length > 0 && db.collection(name).insertMany(data)), 39 | ) 40 | 41 | setCookie(event, 'mongouuid', uuid) 42 | 43 | event.node.res.statusCode = 201 44 | return { 45 | uuid, 46 | } 47 | }) 48 | -------------------------------------------------------------------------------- /composables/useUsers.ts: -------------------------------------------------------------------------------- 1 | import type { MaybeRef } from '@vueuse/core' 2 | import type { User } from '~~/types/User' 3 | 4 | export const useUserName = (user: MaybeRef) => { 5 | const u = unref(user) 6 | return u.profile.display_name || u.profile.real_name || u.name || u.real_name || `${u.profile.first_name} ${u.profile.last_name}` 7 | } 8 | 9 | export function useWithUsernames() { 10 | const { t } = useI18n() 11 | 12 | const users = useUsers() 13 | 14 | const findUser = (id: string) => users.value.find(u => u.id === id) 15 | 16 | const withUsernames = (userIds: MaybeRefOrGetter) => { 17 | const keyedMembers = toValue(userIds).reduce(({ usernames, unknown }, id) => { 18 | const user = findUser(id) 19 | let username = user && useUserName(user) 20 | if (username) { 21 | return { 22 | usernames: [...usernames, username], 23 | unknown, 24 | } 25 | } 26 | return { 27 | usernames, 28 | unknown: unknown + 1, 29 | } 30 | }, { usernames: [] as string[], unknown: 0 }); 31 | 32 | let memberString = keyedMembers.usernames.join(', ') 33 | 34 | if (keyedMembers.unknown > 0) { 35 | if (memberString.length > 0) { 36 | memberString += ' & ' 37 | } 38 | memberString += t('user.unknown', keyedMembers.unknown) 39 | } 40 | 41 | return { keyedMembers, memberString } 42 | } 43 | 44 | return { withUsernames } 45 | } 46 | -------------------------------------------------------------------------------- /.github/workflows/code-ql.yml: -------------------------------------------------------------------------------- 1 | name: Code Scanning - Action 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | schedule: 9 | # ┌───────────── minute (0 - 59) 10 | # │ ┌───────────── hour (0 - 23) 11 | # │ │ ┌───────────── day of the month (1 - 31) 12 | # │ │ │ ┌───────────── month (1 - 12 or JAN-DEC) 13 | # │ │ │ │ ┌───────────── day of the week (0 - 6 or SUN-SAT) 14 | # │ │ │ │ │ 15 | # │ │ │ │ │ 16 | # │ │ │ │ │ 17 | # * * * * * 18 | - cron: '30 1 * * 0' 19 | 20 | jobs: 21 | CodeQL-Build: 22 | # CodeQL runs on ubuntu-latest, windows-latest, and macos-latest 23 | runs-on: ubuntu-latest 24 | 25 | permissions: 26 | # required for all workflows 27 | security-events: write 28 | 29 | steps: 30 | - name: Checkout repository 31 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 32 | 33 | # Initializes the CodeQL tools for scanning. 34 | - name: Initialize CodeQL 35 | uses: github/codeql-action/init@v4 36 | with: 37 | languages: javascript 38 | 39 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 40 | # If this step fails, then you should remove it and run the build manually (see below). 41 | - name: Autobuild 42 | uses: github/codeql-action/autobuild@v4 43 | 44 | - name: Perform CodeQL Analysis 45 | uses: github/codeql-action/analyze@v4 46 | -------------------------------------------------------------------------------- /pages/users/[id].vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 40 | -------------------------------------------------------------------------------- /components/files/DetailRow.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 53 | -------------------------------------------------------------------------------- /composables/useMessages.ts: -------------------------------------------------------------------------------- 1 | import type { Message } from '~/types/Message' 2 | 3 | export const useMessages = (messages: MaybeRefOrGetter) => { 4 | const toDate = useTsToDate() 5 | 6 | const _MS_PER_DAY = 1000 * 60 * 60 * 24 7 | 8 | function dateDiffInDays(a: Date, b: Date) { 9 | // Discard the time and time-zone information. 10 | const utc1 = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate()) 11 | const utc2 = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate()) 12 | 13 | return Math.floor((utc2 - utc1) / _MS_PER_DAY) 14 | } 15 | 16 | const withSeparators = computed(() => { 17 | const msg = toValue(messages) ?? [] 18 | return msg.reduce( 19 | ({ items, date }, message) => { 20 | const messageDate = toDate(message.ts) ?? null 21 | let separator = false 22 | if (date === null) { 23 | separator = true 24 | } 25 | else if (messageDate && date) { 26 | const diff = dateDiffInDays(date, messageDate) 27 | if (diff !== 0) 28 | separator = true 29 | } 30 | if (messageDate && separator && !message.reply) 31 | items.push({ date: messageDate, _id: messageDate.getTime() }) 32 | 33 | items.push(message) 34 | return { 35 | items, 36 | date: message.reply ? date : messageDate, 37 | } 38 | }, 39 | { 40 | items: [] as Array, 41 | date: null as Date | null, 42 | }, 43 | ) 44 | }) 45 | 46 | return { 47 | messages, 48 | withSeparators, 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /components/upload/FileForm.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 58 | -------------------------------------------------------------------------------- /composables/useChannels.ts: -------------------------------------------------------------------------------- 1 | import type { Channel } from '~/types/Channel' 2 | import type { Dm } from '~/types/Dm' 3 | 4 | export function useChannels() { 5 | const properChannels = useState('channels', () => []) 6 | const dms = useState('dms', () => []) 7 | const groups = useState('groups', () => []) 8 | const mpims = useState('mpims', () => []) 9 | 10 | const channels = computed(() => [ 11 | ...properChannels.value, 12 | ...dms.value, 13 | ...groups.value, 14 | ...mpims.value, 15 | ]) 16 | 17 | const load = () => callOnce(async () => { 18 | const [_channels, _dms, _groups, _mpims] = await Promise.all([ 19 | $fetch('/api/channels', { 20 | headers: useRequestHeaders(['cookie']), 21 | }), 22 | $fetch('/api/dms', { 23 | headers: useRequestHeaders(['cookie']), 24 | }), 25 | $fetch('/api/groups', { 26 | headers: useRequestHeaders(['cookie']), 27 | }), 28 | $fetch('/api/mpims', { 29 | headers: useRequestHeaders(['cookie']) 30 | }), 31 | ]) 32 | 33 | properChannels.value = _channels 34 | dms.value = _dms 35 | groups.value = _groups 36 | mpims.value = _mpims 37 | }) 38 | 39 | const typeById = (id: string) => { 40 | if (dms.value.some((dm) => dm.id === id)) return 'dms' 41 | if (groups.value.some((group) => group.name === id)) return 'groups' 42 | if (mpims.value.some((mpim) => mpim.name === id)) return 'mpims' 43 | return 'channels' 44 | } 45 | 46 | 47 | return { properChannels, dms, groups, mpims, channels, load, typeById } 48 | } 49 | -------------------------------------------------------------------------------- /components/message/List.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 60 | -------------------------------------------------------------------------------- /public/Slack_Mark_Monochrome_White.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 9 | 10 | 11 | 13 | 14 | 15 | 16 | 18 | 19 | 20 | 21 | 23 | 24 | 25 | 26 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /public/Slack_Mark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 11 | 12 | 13 | 14 | 16 | 17 | 18 | 19 | 21 | 22 | 23 | 24 | 26 | 27 | 28 | 29 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /server/api/files/index.post.ts: -------------------------------------------------------------------------------- 1 | import type { Filter } from 'mongodb' 2 | import { mongo } from '~~/server/utils/mongo' 3 | import { Sortable } from '~~/types/Sort' 4 | import type { SearchResult } from '~~/types/File' 5 | import type { Message } from '~~/types/Message' 6 | 7 | interface Body { 8 | channels: string[] 9 | users: string[] 10 | sort: Sortable 11 | page: number 12 | size: number 13 | } 14 | 15 | const mongoSortFromBody = ( 16 | sort: Sortable, 17 | ): Record<`files.${string}`, number> => { 18 | switch (sort) { 19 | case Sortable.AtoZ: 20 | return { 'files.name': 1 } 21 | case Sortable.ZtoA: 22 | return { 'files.name': -1 } 23 | case Sortable.Newest: 24 | return { 'files.timestamp': -1 } 25 | case Sortable.Oldest: 26 | return { 'files.timestamp': 1 } 27 | default: 28 | throw createError({ statusCode: 400, statusMessage: 'Unknown sorting' }) 29 | } 30 | } 31 | 32 | export default defineEventHandler(async (event) => { 33 | const db = await mongo(event.context.mongouuid) 34 | 35 | const { users, channels, sort, page, size } = await readBody(event) 36 | 37 | const filter: Filter = { 38 | 'files.name': { $exists: true }, 39 | } 40 | 41 | if (users?.length > 0) 42 | filter.user = { $in: users } 43 | 44 | if (channels?.length > 0) 45 | filter.channel = { $in: channels } 46 | 47 | const sorting = mongoSortFromBody(sort) 48 | 49 | const coll = db.collection('messages') 50 | 51 | const messages = await coll 52 | .aggregate([ 53 | { 54 | $unwind: '$files', 55 | }, 56 | { 57 | $match: filter, 58 | }, 59 | { 60 | $sort: sorting, 61 | }, 62 | ]) 63 | .skip(page * size) 64 | .limit(size) 65 | .toArray() 66 | 67 | const count = await coll.countDocuments(filter) 68 | 69 | return { count: Math.ceil(count / size), messages } 70 | }) 71 | -------------------------------------------------------------------------------- /app.vue: -------------------------------------------------------------------------------- 1 | 80 | 81 | 89 | -------------------------------------------------------------------------------- /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - "*" 7 | tags: 8 | - "*" 9 | 10 | env: 11 | # Use docker.io for Docker Hub if empty 12 | REGISTRY: ghcr.io 13 | # github.repository as / 14 | IMAGE_NAME: ${{ github.repository }} 15 | 16 | jobs: 17 | docker: 18 | runs-on: ubuntu-latest 19 | permissions: 20 | contents: read 21 | packages: write 22 | steps: 23 | - name: Set up QEMU 24 | uses: docker/setup-qemu-action@v3 25 | - name: Set up Docker Buildx 26 | uses: docker/setup-buildx-action@v3 27 | - name: Login to Container Registry 28 | uses: docker/login-action@v3 29 | with: 30 | registry: ${{ env.REGISTRY }} 31 | username: ${{ github.actor }} 32 | password: ${{ secrets.GITHUB_TOKEN }} 33 | - name: Extract Docker Metadata 34 | id: meta 35 | uses: docker/metadata-action@v5 36 | with: 37 | images: | 38 | ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 39 | chris5896/slack-vuesualizer 40 | tags: | 41 | type=ref,event=branch 42 | type=semver,pattern={{version}} 43 | - name: Login to DockerHub 44 | uses: docker/login-action@v3 45 | with: 46 | username: ${{ secrets.DOCKERHUB_USERNAME }} 47 | password: ${{ secrets.DOCKERHUB_TOKEN }} 48 | - name: Set build date 49 | id: builddate 50 | run: echo "date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT 51 | - name: Build and push 52 | uses: docker/build-push-action@v6 53 | with: 54 | build-args: | 55 | VERSION=${{ steps.meta.outputs.version }} 56 | BUILD_DATE=${{ steps.builddate.outputs.date }} 57 | platforms: linux/amd64,linux/arm64 58 | push: true 59 | tags: ${{ steps.meta.outputs.tags }} 60 | labels: ${{ steps.meta.outputs.labels }} 61 | cache-from: type=gha 62 | cache-to: type=gha,mode=max 63 | -------------------------------------------------------------------------------- /components/base/Select.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 52 | -------------------------------------------------------------------------------- /components/channel/Header.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 63 | -------------------------------------------------------------------------------- /pages/users/index.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 57 | 58 | 61 | -------------------------------------------------------------------------------- /components/nav/LocaleChanger.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 63 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "slack-vuesualizer", 3 | "version": "v1.3.1", 4 | "private": true, 5 | "description": "A visualizer for your Slack exports. Search through messages, show old files, etc.", 6 | "author": { 7 | "name": "Chris-Robin Ennen", 8 | "email": "slack-vuesualizer@ennen.dev" 9 | }, 10 | "license": "MIT", 11 | "homepage": "https://github.com/4350pChris/slack-vuesualizer", 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/4350pChris/slack-vuesualizer.git" 15 | }, 16 | "keywords": [ 17 | "slack", 18 | "export", 19 | "import", 20 | "workspace", 21 | "search", 22 | "mongodb", 23 | "nuxt", 24 | "nuxt3", 25 | "vue", 26 | "vue.js", 27 | "typescript" 28 | ], 29 | "scripts": { 30 | "build": "nuxt build", 31 | "dev": "nuxt dev", 32 | "generate": "nuxt generate", 33 | "preview": "nuxt preview" 34 | }, 35 | "dependencies": { 36 | "@headlessui/tailwindcss": "0.2.2", 37 | "@headlessui/vue": "^1.7.19", 38 | "@nuxtjs/color-mode": "3.5.2", 39 | "@nuxtjs/robots": "^5.0.0", 40 | "@nuxtjs/tailwindcss": "6.14.0", 41 | "@tailwindcss/typography": "0.5.19", 42 | "@vuepic/vue-datepicker": "^11.0.0", 43 | "@vueuse/core": "14.0.0", 44 | "@vueuse/nuxt": "14.0.0", 45 | "@zip.js/zip.js": "2.8.10", 46 | "async-sema": "3.1.1", 47 | "daisyui": "4.12.24", 48 | "emoji-js": "3.9.0", 49 | "filesize": "11.0.13", 50 | "gsap": "^3.12.5", 51 | "mongodb": "6.21.0", 52 | "vue-virtual-scroller": "2.0.0-beta.8" 53 | }, 54 | "devDependencies": { 55 | "@antfu/eslint-config": "6.2.0", 56 | "@iconify-json/ion": "1.2.6", 57 | "@iconify-json/line-md": "1.2.11", 58 | "@iconify-json/logos": "1.2.10", 59 | "@iconify-json/mdi": "1.2.3", 60 | "@iconify-json/twemoji": "1.2.4", 61 | "@nuxt/devtools": "^2.0.0", 62 | "@nuxt/icon": "1.15.0", 63 | "@nuxtjs/i18n": "^9.5.6", 64 | "@types/adm-zip": "0.5.7", 65 | "@types/emoji-js": "3.5.2", 66 | "@types/request": "2.48.13", 67 | "autoprefixer": "10.4.22", 68 | "eslint": "9.39.1", 69 | "nuxt": "^3.17.6", 70 | "nuxt-headlessui": "^1.1.4", 71 | "ufo": "^1.3.1", 72 | "unplugin-icons": "^22.1.0" 73 | }, 74 | "volta": { 75 | "node": "22.21.1" 76 | }, 77 | "packageManager": "pnpm@10.22.0+sha512.bf049efe995b28f527fd2b41ae0474ce29186f7edcb3bf545087bd61fbbebb2bf75362d1307fda09c2d288e1e499787ac12d4fcb617a974718a6051f2eee741c" 78 | } 79 | -------------------------------------------------------------------------------- /pages/channels/[channel].vue: -------------------------------------------------------------------------------- 1 | 60 | 61 | 78 | -------------------------------------------------------------------------------- /pages/files/index.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 75 | -------------------------------------------------------------------------------- /components/message/Item.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 65 | -------------------------------------------------------------------------------- /pages/channels/index.vue: -------------------------------------------------------------------------------- 1 | 48 | 49 | 67 | -------------------------------------------------------------------------------- /types/Message.ts: -------------------------------------------------------------------------------- 1 | import type { Profile } from './User' 2 | import type { File } from './File' 3 | 4 | export interface Reaction { 5 | name: string 6 | users: string[] 7 | count: number 8 | } 9 | 10 | export interface Reply { 11 | user: string 12 | ts: string 13 | } 14 | 15 | export interface Attachment { 16 | id: number 17 | color: string 18 | fallback: string 19 | title_link: string 20 | } 21 | 22 | export interface TextLeaf { 23 | style?: { 24 | bold?: boolean 25 | italic?: boolean 26 | strike?: boolean 27 | code?: boolean 28 | } 29 | type: 'text' 30 | text: string 31 | } 32 | 33 | export interface EmojiLeaf { 34 | type: 'emoji' 35 | name: string 36 | unicode?: string 37 | } 38 | 39 | export interface UserLeaf { 40 | type: 'user' 41 | user_id: string 42 | } 43 | 44 | export interface BroadcastLeaf { 45 | type: 'broadcast' 46 | range: string 47 | } 48 | 49 | export interface LinkLeaf { 50 | type: 'link' 51 | url: string 52 | } 53 | 54 | export interface ChannelLeaf { 55 | type: 'channel' 56 | channel_id: string 57 | } 58 | 59 | export interface RichTextQuote { 60 | type: 'rich_text_quote' 61 | elements: Block[] 62 | } 63 | 64 | export interface RichTextPreformatted { 65 | type: 'rich_text_preformatted' 66 | elements: Block[] 67 | } 68 | 69 | export interface RichTextSection { 70 | type: 'rich_text_section' 71 | elements: Block[] 72 | } 73 | 74 | export interface RichTextList { 75 | type: 'rich_text_list' 76 | elements: Block[] 77 | indent: number 78 | border?: number 79 | style: 'ordered' | 'bullet' 80 | } 81 | 82 | export interface RichText { 83 | type: 'rich_text' 84 | block_id: string 85 | elements: Block[] 86 | } 87 | 88 | export type Block = 89 | | RichText 90 | | RichTextSection 91 | | RichTextQuote 92 | | RichTextList 93 | | RichTextPreformatted 94 | | TextLeaf 95 | | EmojiLeaf 96 | | BroadcastLeaf 97 | | LinkLeaf 98 | | UserLeaf 99 | | ChannelLeaf 100 | 101 | export interface ApiMessage { 102 | _id: string 103 | type: string 104 | channel: string 105 | subtype?: string 106 | ts: string 107 | user?: string 108 | bot_id?: string 109 | text: string 110 | user_profile?: Pick< 111 | Profile, 112 | 'image_72' | 'first_name' | 'real_name' | 'display_name' 113 | > & { avatar_hash: string } 114 | reactions?: Reaction[] 115 | replies?: Reply[] 116 | reply_users?: string[] 117 | reply_users_count?: number 118 | reply_count?: number 119 | files?: File[] 120 | blocks?: Block[] 121 | attachments?: Attachment[] 122 | } 123 | 124 | export type Message = Omit & { 125 | reply?: boolean 126 | last_reply?: boolean 127 | } 128 | -------------------------------------------------------------------------------- /components/upload/Worker.vue: -------------------------------------------------------------------------------- 1 | 51 | 52 | 86 | -------------------------------------------------------------------------------- /i18n/locales/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "message": "message | messages", 3 | "reply": "reply | replies", 4 | "user": { 5 | "word": "user | users", 6 | "unknown": "unknown user | {n} unknown users", 7 | "contact": "contact information" 8 | }, 9 | "search": { 10 | "channelresults": "@:channel.word {0} | all @:channel.word", 11 | "messages": "search messages", 12 | "results": "search results for {0} in {1}", 13 | "everywhere": "search everywhere", 14 | "users": "search users", 15 | "channels": "search @:channel.word" 16 | }, 17 | "channel": { 18 | "word": "channel | channels", 19 | "created": "created on {when} by {who}", 20 | "all": "all channels", 21 | "empty": "This @:channel.word is empty." 22 | }, 23 | "github": "view on github", 24 | "noresults": "no results found", 25 | "close": "close", 26 | "language": "language", 27 | "changeLanguage": "change language", 28 | "thisLanguage": "english", 29 | "switchTheme": "switch theme", 30 | "upload": { 31 | "word": "upload", 32 | "inProgress": "File is uploading. Do not worry, this may take a couple of seconds.", 33 | "success": "Upload successful!", 34 | "done": "Here's your link. You may share it with others to give them access to your Slack archive.", 35 | "button": "choose file (.zip)", 36 | "delete": "delete uploaded export", 37 | "full": "Unfortunately, we cannot accept any more uploads at this time. Please try again later." 38 | }, 39 | "token": { 40 | "copy": "copy invite link", 41 | "copied": "copied!" 42 | }, 43 | "workspace": { 44 | "word": "workspace", 45 | "open": "open @:workspace.word", 46 | "yours": "your @:workspace.word contains", 47 | "leave": "leave @:workspace.word", 48 | "delete": { 49 | "button": "delete @:workspace.word", 50 | "confirm": "Are you sure you want to delete this @:workspace.word ? This action cannot be undone. To confirm, enter {0} below." 51 | } 52 | }, 53 | "or": "or", 54 | "abort": "abort", 55 | "next": "next", 56 | "back": "back", 57 | "stepper": { 58 | "choose": "choose file", 59 | "channels": "select channels", 60 | "profit": "profit", 61 | "wrongfile": "That is not a Slack export file. Please try again." 62 | }, 63 | "retry": "retry", 64 | "description": "Show your Slack archive in a searchable way.\nNo registration required - just upload your export and share the link.\nIt's that easy.", 65 | "exporthelp": "How to export my Slack data", 66 | "jumpToDate": "jump to date", 67 | "file": "file | files", 68 | "totalFiles": "{0} total @:file", 69 | "hiddenFiles": "{0} @:file hidden", 70 | "filter": { 71 | "header": "filter", 72 | "sort": "sort", 73 | "from": "from", 74 | "in": "in", 75 | "atoz": "A to Z", 76 | "ztoa": "Z to A", 77 | "oldest": "oldest first", 78 | "newest": "newest first" 79 | }, 80 | "dm": { 81 | "word": "direct message | direct messages" 82 | }, 83 | "group": { 84 | "word": "group | groups" 85 | }, 86 | "mpims": { 87 | "word": "private channel | private channels" 88 | }, 89 | "demo": "Take a peek at the demo Slack export featuring Rick and Morty!", 90 | "members": "one member | {n} members" 91 | } 92 | -------------------------------------------------------------------------------- /i18n/locales/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "message": "Nachricht | Nachrichten", 3 | "reply": "Antwort | Antworten", 4 | "user": { 5 | "word": "Benutzer | Benutzer", 6 | "unknown": "Unbekannter Benutzer | {n} Unbekannte Benutzer", 7 | "contact": "Kontaktinformation" 8 | }, 9 | "search": { 10 | "channelresults": "@:channel.word {0} | allen @:channel.word", 11 | "messages": "suchen", 12 | "results": "Suchergebnisse für {0} in {1}", 13 | "everywhere": "überall suchen", 14 | "users": "@:users suchen", 15 | "channels": "@:channel.word suchen" 16 | }, 17 | "channel": { 18 | "word": "Channel | Channels", 19 | "created": "Erstellt am {when} von {who}", 20 | "all": "Alle @:channel.word", 21 | "empty": "Dieser @:channel.word ist leer." 22 | }, 23 | "github": "zu Github", 24 | "noresults": "keine Ergebnisse", 25 | "close": "schließen", 26 | "language": "Sprache", 27 | "changeLanguage": "Sprache ändern", 28 | "thisLanguage": "deutsch", 29 | "switchTheme": "Theme ändern", 30 | "upload": { 31 | "word": "Import", 32 | "inProgress": "Datei wird hochgeladen. Das könnte eine Minute dauern....", 33 | "success": "Import erfolgreich!", 34 | "done": "Hier ist dein Link. Teile ihn mit anderen um ihnen Zugang zu dem Slack Archiv zu geben.", 35 | "button": "Datei auswählen (.zip)", 36 | "delete": "Lösche hochgeladene Datei", 37 | "full": "Leider gibt es keinen Platz für weitere Workspaces." 38 | }, 39 | "token": { 40 | "copy": "Einladungslink kopieren", 41 | "copied": "kopiert!" 42 | }, 43 | "workspace": { 44 | "word": "Workspace", 45 | "open": "Workspace öffnen", 46 | "yours": "dein Workspace enthält", 47 | "leave": "Workspace verlassen", 48 | "delete": { 49 | "button": "@:workspace.word löschen", 50 | "confirm": "Bist du dir sicher, dass du diesen @:workspace.word löschen möchtest? Dies kann nicht rückgängig gemacht werden. Wenn ja, gib {0} in das Textfeld ein." 51 | } 52 | }, 53 | "or": "oder", 54 | "abort": "abbrechen", 55 | "next": "weiter", 56 | "back": "zurück", 57 | "stepper": { 58 | "choose": "Dateiauswahl", 59 | "channels": "Channelauswahl", 60 | "profit": "Profit", 61 | "wrongfile": "Das ist kein Slack Export. Versuch's nochmal." 62 | }, 63 | "retry": "wiederholen", 64 | "description": "Sieh und durchsuche dein Slack Archiv.\nKeine Registrierung nötig - nur den Export hochladen und den Link mit anderen teilen.\nGanz einfach.", 65 | "exporthelp": "Wie erstelle ich einen Slack Export", 66 | "jumpToDate": "springe zu Datum", 67 | "file": "Datei | Dateien", 68 | "totalFiles": "{0} @:file insgesamt", 69 | "hiddenFiles": "{0} @:file versteckt", 70 | "filter": { 71 | "header": "Filtern", 72 | "sort": "Sortierung", 73 | "from": "von", 74 | "in": "in", 75 | "atoz": "A bis Z", 76 | "ztoa": "Z bis A", 77 | "oldest": "älteste zuerst", 78 | "newest": "neueste zuerst" 79 | }, 80 | "dm": { 81 | "word": "Direktnachricht | Direktnachrichten" 82 | }, 83 | "group": { 84 | "word": "Gruppe | Gruppen" 85 | }, 86 | "mpims": { 87 | "word": "privater Channel | private Channel" 88 | }, 89 | "demo": "Wirf einen Blick auf den Demo-Slack-Export mit Rick und Morty!", 90 | "members": "Mitglied | Mitglieder" 91 | } 92 | -------------------------------------------------------------------------------- /components/upload/Stepper.vue: -------------------------------------------------------------------------------- 1 | 69 | 70 | 119 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Slack Vuesualizer 2 | 3 | Slack 4 | 5 | Are you on the free plan of Slack and can't access your old messages anymore? 6 | Then this is the tool for you! 7 | 8 | Slack Vuesualizer is a web app to view, search and share your old Slack messages. 9 | 10 | ![Screenshot](./public/screenshot.png) 11 | 12 | Use the hosted version at [https://slack-vuesualizer.de/](https://slack-vuesualizer.de/) for free or spin up your own website using the Docker image as [described below](#setup). 13 | 14 | ## Demo 15 | 16 | You can try out the demo workspace at [https://slack-vuesualizer.de/?token=5f305938-a64d-441d-9146-df23d8b52f18](https://slack-vuesualizer.de/?token=5f305938-a64d-441d-9146-df23d8b52f18). 17 | 18 | ## Features 19 | 20 | * full-text search for up to tens of thousands of messages per channel 21 | * view all messages per channel with proper formatting, files, etc. 22 | * view and search through all users 23 | * pleasant UI 24 | 25 | ## Setup 26 | 27 | ### Docker 28 | 29 | The easiest way to get started is to use the Docker image. 30 | For this you'll need to have [Docker](https://www.docker.com/) installed on your machine. 31 | 32 | Next, copy the `docker-compose.yml` file from this repository to your machine. 33 | From the directory where the file is located, open a terminal and run: 34 | 35 | ```bash 36 | docker compose up 37 | ``` 38 | 39 | That's it! Docker will download the images and start the app on [http://localhost:3000](http://localhost:3000). 40 | 41 | #### Images 42 | 43 | There are Docker images for amd64 and arm64 available at [hub.docker.io/chris5896/slack-vuesualizer](https://hub.docker.com/repository/docker/chris5896/slack-vuesualizer) as well as the GitHub Container Registry [https://ghcr.io/4350pchris/slack-vuesualizer](https://ghcr.io/4350pchris/slack-vuesualizer) 44 | 45 | Every Branch gets its own tag and is released. 46 | 47 | All the files to build a local image can be found in this repository as well. 48 | 49 | ## Contributing 50 | 51 | Contributions are welcome! Feel free to fork this repository and open a pull request. 52 | 53 | If you have an idea for a feature or a bug to report, feel free to open an issue. 54 | 55 | ### Development 56 | 57 | Look at the [nuxt 3 documentation](https://nuxt.com) to learn more. 58 | 59 | Make sure to install the dependencies: 60 | 61 | ```bash 62 | npm install 63 | ``` 64 | 65 | #### MongoDB 66 | 67 | This project contains a Docker Compose file to start a local MongoDB instance. You can start it with: 68 | 69 | ```bash 70 | docker compose -f docker-compose.dev.yml up 71 | ``` 72 | 73 | #### Development Server 74 | 75 | Start the development server on [http://localhost:3000](http://localhost:3000) 76 | 77 | ```bash 78 | npm run dev 79 | ``` 80 | 81 | #### Production (Preview) Server 82 | 83 | Build the application for production: 84 | 85 | ```bash 86 | npm run build 87 | ``` 88 | 89 | Locally preview production build: 90 | 91 | ```bash 92 | npm run preview 93 | ``` 94 | 95 | Checkout the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information. 96 | 97 | ## Technologies 98 | 99 | * [Nuxt 3](https://v3.nuxtjs.org/) 100 | * [TailwindCSS](https://tailwindcss.com/) and [DaisyUI](https://daisyui.com) 101 | * [Iconify](https://github.com/iconify/iconify) 102 | * [MongoDB](https://www.mongodb.com/) 103 | * [Docker](https://www.docker.com/) 104 | * [Vercel](https://vercel.com/) 105 | -------------------------------------------------------------------------------- /composables/useUpload.ts: -------------------------------------------------------------------------------- 1 | import { Sema } from 'async-sema' 2 | import { FetchError } from "ofetch" 3 | import type { Entry } from '@zip.js/zip.js' 4 | 5 | export const useUpload = () => { 6 | const queue = ref(new Set()) 7 | const done = ref(new Set()) 8 | const errors = ref(new Set()) 9 | const retriable = ref(false) 10 | const full = ref(false) 11 | 12 | const { parseData } = useZip() 13 | 14 | const list = ref() 15 | 16 | const sema = new Sema(3) 17 | 18 | const uploadChannel = async (channel: string, entries: Entry[]) => { 19 | try { 20 | await sema.acquire() 21 | queue.value.add(channel) 22 | const channelEntries = entries.filter( 23 | e => !e.directory && e.filename.startsWith(`${channel}/`), 24 | ) 25 | 26 | const data = await parseData(channelEntries) 27 | 28 | // split into groups to prevent request from being too large for Vercel to handle 29 | const groups = [] 30 | const size = 500 31 | 32 | for (let i = 0; i < data.length; i += size) 33 | groups.push(data.slice(i, i + size)) 34 | 35 | await Promise.all( 36 | groups.map(group => 37 | $fetch(`/api/import/channel/${channel}`, { 38 | method: 'POST', 39 | body: { data: group }, 40 | }), 41 | ), 42 | ) 43 | 44 | done.value.add(channel) 45 | } 46 | catch (e) { 47 | errors.value.add(channel) 48 | } 49 | finally { 50 | queue.value.delete(channel) 51 | sema.release() 52 | } 53 | } 54 | 55 | const uploadWorkspaceData = async (channels: string[], entries: Entry[]) => { 56 | try { 57 | queue.value.add('vuesualizer-workspace') 58 | const workspaceEntries = entries.filter( 59 | e => !e.filename.includes('/') && !e.directory, 60 | ) 61 | let data = await Promise.all( 62 | workspaceEntries.map(async e => ({ 63 | name: e.filename.split('.json')[0], 64 | data: await parseData([e]), 65 | })), 66 | ) 67 | 68 | // remove channels that are not to be imported 69 | data = data.map((d) => { 70 | if (d.name !== 'channels') 71 | return d 72 | 73 | d.data = d.data.filter(c => channels.includes(c.name)) 74 | return d 75 | }) 76 | 77 | // guard against empty datasets 78 | data = data.filter(d => !(d.data.length === 1 && d.data[0] === '')) 79 | 80 | await $fetch('/api/import/workspace', { 81 | method: 'POST', 82 | body: { data }, 83 | }) 84 | 85 | done.value.add('vuesualizer-workspace') 86 | } 87 | catch (e) { 88 | errors.value.add('vuesualizer-workspace') 89 | 90 | if (e instanceof FetchError && e.response?.status === 409) { 91 | full.value = true 92 | } 93 | 94 | throw e 95 | } 96 | finally { 97 | queue.value.delete('vuesualizer-workspace') 98 | } 99 | } 100 | 101 | const doUpload = async (channels: string[], entries: Entry[]) => { 102 | retriable.value = false 103 | 104 | if (!done.value.has('vuesualizer-workspace')) 105 | await uploadWorkspaceData(channels, entries) 106 | 107 | const channelsToUpload = channels.filter( 108 | channel => !done.value.has(channel), 109 | ) 110 | 111 | await Promise.all(channelsToUpload.map(channel => uploadChannel(channel, entries))) 112 | 113 | if (done.value.size === channels.length + 1) 114 | return true 115 | 116 | retriable.value = true 117 | return false 118 | } 119 | 120 | return { 121 | queue, 122 | done, 123 | errors, 124 | full, 125 | retriable, 126 | list, 127 | doUpload, 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /components/message/Search.vue: -------------------------------------------------------------------------------- 1 | 51 | 52 | 145 | --------------------------------------------------------------------------------