├── .prettierignore ├── .yarnrc.yml ├── public ├── _redirects ├── favicon.ico ├── img │ ├── cactus-lendit.jpg │ ├── default-avatar.png │ └── icons │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── apple-touch-icon.png │ │ ├── android-chrome-192x192.png │ │ └── android-chrome-512x512.png ├── robots.txt ├── manifest.json └── index.html ├── example-showcase.png ├── src ├── shims-vue.d.ts ├── store │ ├── state.ts │ ├── index.ts │ └── main │ │ ├── index.ts │ │ ├── state.ts │ │ ├── getters.ts │ │ └── mutations.ts ├── dayjsPlugin.ts ├── components │ ├── RouterComponent.vue │ ├── EmptyPlaceholder.vue │ ├── base │ │ ├── ValidateUrl.vue │ │ ├── DebugSpan.vue │ │ ├── BaseCard.vue │ │ ├── RotationCard.vue │ │ └── RotationList.vue │ ├── icons │ │ ├── WebIcon.vue │ │ ├── AnswerIcon.vue │ │ ├── DoorIcon.vue │ │ ├── FlagIcon.vue │ │ ├── HandleIcon.vue │ │ ├── HomeIcon.vue │ │ ├── LinkIcon.vue │ │ ├── PasswordIcon.vue │ │ ├── CloseIcon.vue │ │ ├── EditIcon.vue │ │ ├── EmailIcon.vue │ │ ├── GithubIcon.vue │ │ ├── LogoutIcon.vue │ │ ├── PrivateSiteIcon.vue │ │ ├── ReplyIcon.vue │ │ ├── SharingIcon.vue │ │ ├── AccountIcon.vue │ │ ├── HelpIcon.vue │ │ ├── HistoryIcon.vue │ │ ├── HomeFabIcon.vue │ │ ├── I18nIcon.vue │ │ ├── LinkedinIcon.vue │ │ ├── NotificationIcon.vue │ │ ├── RefreshIcon.vue │ │ ├── SearchIcon.vue │ │ ├── ShareIcon.vue │ │ ├── SiteIcon.vue │ │ ├── TwitterIcon.vue │ │ ├── UpIcon.vue │ │ ├── BookshelfIcon.vue │ │ ├── BroadcastIcon.vue │ │ ├── CellphoneIcon.vue │ │ ├── DownIcon.vue │ │ ├── ExploreIcon.vue │ │ ├── LeftIcon.vue │ │ ├── MenuIcon.vue │ │ ├── OpenInNewIcon.vue │ │ ├── RegisteredUserOnlyIcon.vue │ │ ├── RightIcon.vue │ │ ├── SettingsIcon.vue │ │ ├── UpvotedIcon.vue │ │ ├── VerifyCodeIcon.vue │ │ ├── AnyoneVisibilityIcon.vue │ │ ├── ChannelIcon.vue │ │ ├── DeleteIcon.vue │ │ ├── DotsIcon.vue │ │ ├── LockOutlineIcon.vue │ │ ├── MuteNotificationIcon.vue │ │ ├── ProfileIcon.vue │ │ ├── ShieldCheckIcon.vue │ │ ├── TransferIcon.vue │ │ ├── BookmarkedIcon.vue │ │ ├── DashboardIcon.vue │ │ ├── FeedIcon.vue │ │ ├── JoinChafanIcon.vue │ │ ├── CollapseUpIcon.vue │ │ ├── EmailEditOutline.vue │ │ ├── InfoIcon.vue │ │ ├── RegisteredVisibilityIcon.vue │ │ ├── UpvoteIcon.vue │ │ ├── CommentsIcon.vue │ │ ├── FeedbackIcon.vue │ │ ├── HelpCircleOutline.vue │ │ ├── MessageTextLockIcon.vue │ │ ├── ReactionIcon.vue │ │ ├── ToBookmarkIcon.vue │ │ ├── ExpandHorizontalIcon.vue │ │ ├── MoreIcon.vue │ │ ├── AccountCircleOutlineIcon.vue │ │ ├── CollapseHorizontalIcon.vue │ │ └── ZhihuIcon.vue │ ├── widgets │ │ ├── UpvoteStat.vue │ │ ├── UpvoteBtn.vue │ │ ├── TopicChip.vue │ │ ├── UpvotedBtn.vue │ │ ├── CommentBtn.vue │ │ ├── DiffView.vue │ │ ├── ContribGraphs.vue │ │ └── VerificationCodeBtn.vue │ ├── WorkExp.vue │ ├── home │ │ ├── UserLogoutWelcome.vue │ │ ├── UserSubmissionsRankedFeed.vue │ │ ├── UserAgreement.vue │ │ └── UserWelcome.vue │ ├── editor │ │ └── EditorHelp.vue │ ├── UserGrid.vue │ ├── question │ │ ├── QuestionLink.vue │ │ ├── QuestionUpvotes.vue │ │ └── QuestionPreview.vue │ ├── SiteName.vue │ ├── UserNameHeadline.vue │ ├── SiteJoinConditions.vue │ ├── Avatar.vue │ ├── CommentPreview.vue │ ├── TopicSearch.vue │ ├── UIStyleControllers.vue │ ├── EduExp.vue │ ├── image │ │ └── LightboxGroup.vue │ ├── Viewer.vue │ ├── ArticlePreview.vue │ ├── UserLink.vue │ ├── submission │ │ └── SubmissionUpvotes.vue │ ├── ActivitySubject.vue │ ├── ShareCardButton.vue │ ├── SiteSearch.vue │ ├── SiteBtn.vue │ ├── CommentCard.vue │ ├── Invite.vue │ ├── Upvote.vue │ ├── NotificationsManager.vue │ ├── DynamicItemList.vue │ ├── NewInviteLinkBtn.vue │ ├── SimpleEditor.vue │ ├── TopicCard.vue │ ├── ExploreSitesGrid.vue │ ├── SearchBox.vue │ ├── CommentBlock.vue │ ├── SubmissionPreview.vue │ └── CreateQuestionForm.vue ├── component-hooks.ts ├── dayjs.ts ├── composables │ ├── index.ts │ ├── useEnv.ts │ ├── useTheme.ts │ ├── useDayjs.ts │ ├── useAuth.ts │ ├── useResponsive.ts │ └── useNotification.ts ├── editorPlugins.ts ├── shims-tsx.d.ts ├── views │ ├── auth │ │ └── Login.vue │ ├── showcase │ │ └── ExampleSubmissionPreview.vue │ ├── main │ │ ├── Search.vue │ │ ├── dashboard │ │ │ ├── Feedback.vue │ │ │ └── SiteCreation.vue │ │ ├── Chat.vue │ │ ├── CreateArticleColumn.vue │ │ └── ArticleColumn.vue │ └── PasswordRecovery.vue ├── plugins │ ├── vuetify.ts │ └── vee-validate.ts ├── api │ ├── webhook.ts │ ├── forms.ts │ ├── topic.ts │ ├── discovery.ts │ ├── comment.ts │ ├── activity.ts │ ├── search.ts │ ├── site.ts │ ├── question.ts │ ├── answer.ts │ ├── people.ts │ └── submission.ts ├── logging.ts ├── apiWrapper.ts ├── registerServiceWorker.ts ├── env.ts ├── api2.ts ├── imagelib.ts └── App.vue ├── .eslintignore ├── .prettierrc ├── Makefile ├── pull_request_template.md ├── babel.config.js ├── example.env ├── .gitignore ├── .stylelintrc.json ├── .github └── workflows │ ├── codeql-analysis.yml │ └── main.yml ├── flake.lock ├── tsconfig.json ├── flake.nix ├── README.md ├── cloudflare.build.sh ├── eslintrc.js.bak ├── CODE_OF_CONDUCT.md └── package.json /.prettierignore: -------------------------------------------------------------------------------- 1 | /dist/** 2 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /public/_redirects: -------------------------------------------------------------------------------- 1 | /* /index.html 200 2 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chafan-dev/chafan-pwa/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /example-showcase.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chafan-dev/chafan-pwa/HEAD/example-showcase.png -------------------------------------------------------------------------------- /src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | import Vue from 'vue'; 3 | export default Vue; 4 | } 5 | -------------------------------------------------------------------------------- /public/img/cactus-lendit.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chafan-dev/chafan-pwa/HEAD/public/img/cactus-lendit.jpg -------------------------------------------------------------------------------- /public/img/default-avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chafan-dev/chafan-pwa/HEAD/public/img/default-avatar.png -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # Ignore every js file limits us into src/** and tests/**. 2 | # This file need further tuning 3 | *.js 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false, 4 | "singleQuote": true, 5 | "printWidth": 100 6 | } 7 | -------------------------------------------------------------------------------- /public/img/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chafan-dev/chafan-pwa/HEAD/public/img/icons/favicon-16x16.png -------------------------------------------------------------------------------- /public/img/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chafan-dev/chafan-pwa/HEAD/public/img/icons/favicon-32x32.png -------------------------------------------------------------------------------- /src/store/state.ts: -------------------------------------------------------------------------------- 1 | import { MainState } from './main/state'; 2 | 3 | export interface State { 4 | main: MainState; 5 | } 6 | -------------------------------------------------------------------------------- /public/img/icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chafan-dev/chafan-pwa/HEAD/public/img/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /public/img/icons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chafan-dev/chafan-pwa/HEAD/public/img/icons/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/img/icons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chafan-dev/chafan-pwa/HEAD/public/img/icons/android-chrome-512x512.png -------------------------------------------------------------------------------- /src/dayjsPlugin.ts: -------------------------------------------------------------------------------- 1 | import dayjs from '@/dayjs'; 2 | 3 | export default { 4 | install: (Vue) => { 5 | Vue.prototype.$dayjs = dayjs; 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: 2 | yarn install 3 | yarn run build 4 | 5 | fix: 6 | yarn run eslint --fix . 7 | yarn run prettier -w . 8 | 9 | prod-pr: 10 | gh pr create -B prod 11 | -------------------------------------------------------------------------------- /src/components/RouterComponent.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 8 | -------------------------------------------------------------------------------- /pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Related issues/discussions 2 | 3 | 4 | 5 | ## Description of the changes 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/components/EmptyPlaceholder.vue: -------------------------------------------------------------------------------- 1 | 7 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | '@vue/cli-plugin-babel/preset', 5 | { 6 | useBuiltIns: 'entry', 7 | corejs: 3, 8 | }, 9 | ], 10 | ], 11 | }; 12 | -------------------------------------------------------------------------------- /src/components/base/ValidateUrl.vue: -------------------------------------------------------------------------------- 1 | 7 | -------------------------------------------------------------------------------- /example.env: -------------------------------------------------------------------------------- 1 | 2 | export VUE_APP_HOST=cha.fan 3 | 4 | export VUE_APP_API=api_dev2.$VUE_APP_HOST 5 | 6 | export VUE_APP_NAME=ChaFan 7 | 8 | export VUE_APP_ENV=staging 9 | 10 | export VUE_APP_CDN_DOMAIN=cdn.jsdelivr.net 11 | 12 | -------------------------------------------------------------------------------- /src/components/icons/WebIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/component-hooks.ts: -------------------------------------------------------------------------------- 1 | import Component from 'vue-class-component'; 2 | 3 | // Register the router hooks with their names 4 | Component.registerHooks([ 5 | 'beforeRouteEnter', 6 | 'beforeRouteLeave', 7 | 'beforeRouteUpdate', // for vue-router 2.2+ 8 | ]); 9 | -------------------------------------------------------------------------------- /src/components/icons/AnswerIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/DoorIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/FlagIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/HandleIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/HomeIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/LinkIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/PasswordIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/CloseIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/EditIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/EmailIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/GithubIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/LogoutIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/PrivateSiteIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/ReplyIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/SharingIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/AccountIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/HelpIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/HistoryIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/HomeFabIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/I18nIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/LinkedinIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/NotificationIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/RefreshIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/SearchIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/ShareIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/SiteIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/TwitterIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/UpIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/BookshelfIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/BroadcastIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/CellphoneIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/DownIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/ExploreIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/LeftIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/MenuIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/OpenInNewIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/RegisteredUserOnlyIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/RightIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/SettingsIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/UpvotedIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/VerifyCodeIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/AnyoneVisibilityIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/ChannelIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/DeleteIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/DotsIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/LockOutlineIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/MuteNotificationIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/ProfileIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/ShieldCheckIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/TransferIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/dayjs.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | 3 | import 'dayjs/locale/zh-cn'; 4 | 5 | import utc from 'dayjs/plugin/utc'; 6 | import relativeTime from 'dayjs/plugin/relativeTime'; 7 | 8 | dayjs.extend(utc); 9 | dayjs.extend(relativeTime); 10 | 11 | export default dayjs; 12 | -------------------------------------------------------------------------------- /src/components/icons/BookmarkedIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/DashboardIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/FeedIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/JoinChafanIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/CollapseUpIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/EmailEditOutline.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/InfoIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/RegisteredVisibilityIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/UpvoteIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/CommentsIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/FeedbackIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/HelpCircleOutline.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/MessageTextLockIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/ReactionIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/ToBookmarkIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/composables/index.ts: -------------------------------------------------------------------------------- 1 | export { useAuth } from './useAuth'; 2 | export { useTheme } from './useTheme'; 3 | export { useResponsive } from './useResponsive'; 4 | export { useDayjs } from './useDayjs'; 5 | export { useNotification } from './useNotification'; 6 | export { useEnv } from './useEnv'; 7 | -------------------------------------------------------------------------------- /src/components/icons/ExpandHorizontalIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/MoreIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/base/DebugSpan.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 12 | -------------------------------------------------------------------------------- /src/components/icons/AccountCircleOutlineIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/icons/CollapseHorizontalIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/editorPlugins.ts: -------------------------------------------------------------------------------- 1 | const Viewer = () => import('@/components/Viewer.vue'); 2 | const AnswerEditor = () => import('@/components/AnswerEditor.vue'); 3 | 4 | export default { 5 | install: (Vue) => { 6 | Vue.component('Viewer', Viewer); 7 | Vue.component('AnswerEditor', AnswerEditor); 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /src/shims-tsx.d.ts: -------------------------------------------------------------------------------- 1 | import Vue, { VNode } from 'vue'; 2 | 3 | declare global { 4 | namespace JSX { 5 | // tslint:disable no-empty-interface 6 | type Element = VNode; 7 | // tslint:disable no-empty-interface 8 | type ElementClass = Vue; 9 | 10 | interface IntrinsicElements { 11 | [elem: string]: any; 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/composables/useEnv.ts: -------------------------------------------------------------------------------- 1 | import { isDev, isProdDev } from '@/utils'; 2 | 3 | /** 4 | * Composable for environment checks. 5 | * Replaces CVue's isDev and isProdDev properties. 6 | * 7 | * These are not reactive since environment doesn't change at runtime. 8 | */ 9 | export function useEnv() { 10 | return { 11 | isDev, 12 | isProdDev, 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /src/views/auth/Login.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 16 | -------------------------------------------------------------------------------- /src/components/widgets/UpvoteStat.vue: -------------------------------------------------------------------------------- 1 | 7 | 15 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Vuex, { StoreOptions } from 'vuex'; 3 | 4 | import { mainModule } from './main'; 5 | import { State } from './state'; 6 | 7 | Vue.use(Vuex); 8 | 9 | const storeOptions: StoreOptions = { 10 | modules: { 11 | main: mainModule, 12 | }, 13 | }; 14 | 15 | export const store = new Vuex.Store(storeOptions); 16 | 17 | export default store; 18 | -------------------------------------------------------------------------------- /src/plugins/vuetify.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Vuetify from 'vuetify/lib'; 3 | 4 | import zh from 'vuetify/es5/locale/zh-Hans'; 5 | import en from 'vuetify/es5/locale/en'; 6 | 7 | import { getBrowserLocale } from '@/utils'; 8 | 9 | Vue.use(Vuetify); 10 | 11 | export default new Vuetify({ 12 | lang: { 13 | locales: { zh, en }, 14 | current: getBrowserLocale(), 15 | }, 16 | icons: { 17 | iconfont: 'mdiSvg', 18 | }, 19 | }); 20 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # Notice: Collection of data on Chafan through automated means is 2 | # prohibited unless you have express written permission from Chafan 3 | # and may only be conducted for the limited purpose contained in said 4 | # permission. 5 | 6 | User-agent: * 7 | Allow: /$ 8 | Allow: /explore 9 | Allow: /questions/ 10 | Allow: /articles/ 11 | Allow: /submissions/ 12 | Allow: /sites/ 13 | Allow: /users/ 14 | Allow: /topics/ 15 | 16 | User-agent: * 17 | Disallow: / 18 | -------------------------------------------------------------------------------- /src/components/widgets/UpvoteBtn.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 16 | -------------------------------------------------------------------------------- /src/components/WorkExp.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .yarn 2 | .pnp.* 3 | 4 | .DS_Store 5 | paper 6 | node_modules 7 | /dist 8 | 9 | # local env files 10 | .env 11 | pwa.env 12 | .local_env 13 | .env.local 14 | .env.*.local 15 | .start_local_* 16 | 17 | # Log files 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | 22 | # Editor directories and files 23 | .idea 24 | .vscode 25 | *.suo 26 | *.ntvs* 27 | *.njsproj 28 | *.sln 29 | *.sw* 30 | pagekite.py 31 | 32 | *.pem 33 | 34 | # ESLint cache file 35 | .eslintcache 36 | 37 | -------------------------------------------------------------------------------- /src/components/home/UserLogoutWelcome.vue: -------------------------------------------------------------------------------- 1 | 8 | 17 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "茶饭", 3 | "short_name": "茶饭", 4 | "icons": [ 5 | { 6 | "src": "/img/icons/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/img/icons/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "start_url": "/", 17 | "display": "standalone", 18 | "background_color": "#1976d2", 19 | "theme_color": "#1976d2" 20 | } 21 | -------------------------------------------------------------------------------- /src/components/widgets/TopicChip.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 22 | -------------------------------------------------------------------------------- /src/components/widgets/UpvotedBtn.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 18 | -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "stylelint-config-standard", 4 | "stylelint-config-standard-scss", 5 | "stylelint-config-recommended-vue" 6 | ], 7 | "rules": { 8 | "no-descending-specificity": null, 9 | "no-empty-source": null, 10 | "selector-pseudo-class-no-unknown": [ 11 | true, 12 | { 13 | "ignorePseudoClasses": ["deep", "global"] 14 | } 15 | ], 16 | "declaration-no-important": [ 17 | true, 18 | { 19 | "severity": "warning" 20 | } 21 | ] 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/api/webhook.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { IWebhook, IWebhookCreate, IWebhookUpdate } from '@/interfaces'; 3 | import { apiUrl } from '@/env'; 4 | import { authHeaders } from '@/utils'; 5 | 6 | export const apiWebhook = { 7 | async create(token: string, payload: IWebhookCreate) { 8 | return axios.post(`${apiUrl}/webhooks/`, payload, authHeaders(token)); 9 | }, 10 | async update(token: string, id: number, payload: IWebhookUpdate) { 11 | return axios.put(`${apiUrl}/webhooks/${id}`, payload, authHeaders(token)); 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /src/components/editor/EditorHelp.vue: -------------------------------------------------------------------------------- 1 | 15 | 23 | -------------------------------------------------------------------------------- /src/composables/useTheme.ts: -------------------------------------------------------------------------------- 1 | import { computed } from 'vue'; 2 | import { readTheme } from '@/store/main/getters'; 3 | import { themeDefs } from '@/common'; 4 | import store from '@/store'; 5 | 6 | /** 7 | * Composable for theme-related state. 8 | * Replaces CVue's theme property. 9 | * 10 | * Returns the full theme definition object based on current theme setting. 11 | */ 12 | export function useTheme() { 13 | const themeType = computed(() => readTheme(store)); 14 | const theme = computed(() => themeDefs[themeType.value]); 15 | 16 | return { 17 | themeType, 18 | theme, 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /src/components/widgets/CommentBtn.vue: -------------------------------------------------------------------------------- 1 | 12 | 20 | -------------------------------------------------------------------------------- /src/components/UserGrid.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 21 | -------------------------------------------------------------------------------- /src/logging.ts: -------------------------------------------------------------------------------- 1 | import { env } from './env'; 2 | 3 | export default { 4 | info: (message: string) => { 5 | if (env !== 'production') { 6 | console.log(message); 7 | } 8 | }, 9 | error: (message: string) => { 10 | if (env !== 'production') { 11 | console.error(message); 12 | } 13 | }, 14 | }; 15 | 16 | export const warn = (message: string) => { 17 | if (env !== 'production') { 18 | console.warn(message); 19 | } 20 | }; 21 | 22 | export const info = (message: string) => { 23 | if (env !== 'production') { 24 | console.log(message); 25 | } 26 | }; 27 | 28 | -------------------------------------------------------------------------------- /src/components/question/QuestionLink.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 21 | -------------------------------------------------------------------------------- /src/composables/useDayjs.ts: -------------------------------------------------------------------------------- 1 | import dayjs from '@/dayjs'; 2 | 3 | /** 4 | * Composable for dayjs utilities. 5 | * Replaces CVue's fromNow method and this.$dayjs access. 6 | * 7 | * The dayjs instance is pre-configured with: 8 | * - UTC plugin 9 | * - relativeTime plugin 10 | * - zh-cn locale 11 | */ 12 | export function useDayjs() { 13 | /** 14 | * Converts a UTC datetime string to a relative time string (e.g., "2 hours ago") 15 | */ 16 | function fromNow(datetime: string): string { 17 | return dayjs.utc(datetime).local().fromNow(); 18 | } 19 | 20 | return { 21 | dayjs, 22 | fromNow, 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: 'CodeQL' 2 | 3 | on: 4 | push: 5 | branches: [analysis] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | analyze: 11 | name: Analyze 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | language: ['javascript-typescript'] 18 | steps: 19 | - name: Checkout repository 20 | uses: actions/checkout@v4 21 | 22 | # Initializes the CodeQL tools for scanning. 23 | - name: Initialize CodeQL 24 | uses: github/codeql-action/init@v3 25 | - name: Perform CodeQL Analysis 26 | uses: github/codeql-action/analyze@v3 27 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "nixpkgs": { 4 | "locked": { 5 | "lastModified": 1760423683, 6 | "narHash": "sha256-Tb+NYuJhWZieDZUxN6PgglB16yuqBYQeMJyYBGCXlt8=", 7 | "owner": "nixos", 8 | "repo": "nixpkgs", 9 | "rev": "a493e93b4a259cd9fea8073f89a7ed9b1c5a1da2", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "nixos", 14 | "ref": "nixos-25.05", 15 | "repo": "nixpkgs", 16 | "type": "github" 17 | } 18 | }, 19 | "root": { 20 | "inputs": { 21 | "nixpkgs": "nixpkgs" 22 | } 23 | } 24 | }, 25 | "root": "root", 26 | "version": 7 27 | } 28 | -------------------------------------------------------------------------------- /src/components/SiteName.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 25 | -------------------------------------------------------------------------------- /src/store/main/index.ts: -------------------------------------------------------------------------------- 1 | import { mutations } from './mutations'; 2 | import { getters } from './getters'; 3 | import { actions } from './actions'; 4 | import { MainState } from './state'; 5 | 6 | const defaultState: MainState = { 7 | isLoggedIn: null, 8 | token: '', 9 | logInError: false, 10 | userProfile: null, 11 | dashboardMiniDrawer: false, 12 | dashboardShowDrawer: false, 13 | notifications: [], 14 | moderated_sites: null, 15 | workingDraft: null, 16 | topBanner: null, 17 | showLoginPrompt: false, 18 | narrowUI: true, 19 | theme: 'default', 20 | }; 21 | 22 | export const mainModule = { 23 | state: defaultState, 24 | mutations, 25 | actions, 26 | getters, 27 | }; 28 | -------------------------------------------------------------------------------- /src/store/main/state.ts: -------------------------------------------------------------------------------- 1 | import { IRichEditorState, ISite, ITopBanner, IUserProfile, ThemeType } from '@/interfaces'; 2 | 3 | export interface AppNotification { 4 | content: string; 5 | color?: string; 6 | showProgress?: boolean; 7 | } 8 | 9 | export interface MainState { 10 | token: string; 11 | isLoggedIn: boolean | null; 12 | logInError: boolean; 13 | userProfile: IUserProfile | null; 14 | dashboardMiniDrawer: boolean; 15 | dashboardShowDrawer: boolean; 16 | notifications: AppNotification[]; 17 | moderated_sites: ISite[] | null; 18 | workingDraft: IRichEditorState | null; 19 | topBanner: ITopBanner | null; 20 | showLoginPrompt: boolean; 21 | narrowUI: boolean; 22 | theme: ThemeType; 23 | } 24 | -------------------------------------------------------------------------------- /src/composables/useAuth.ts: -------------------------------------------------------------------------------- 1 | import { computed } from 'vue'; 2 | import { readToken, readUserProfile, readIsLoggedIn } from '@/store/main/getters'; 3 | import store from '@/store'; 4 | 5 | /** 6 | * Composable for authentication-related state. 7 | * Replaces CVue's token, loggedIn, currentUserId, and userProfile properties. 8 | */ 9 | export function useAuth() { 10 | const token = computed(() => readToken(store)); 11 | const loggedIn = computed(() => readIsLoggedIn(store)); 12 | const currentUserId = computed(() => readUserProfile(store)?.uuid); 13 | const userProfile = computed(() => readUserProfile(store)); 14 | 15 | return { 16 | token, 17 | loggedIn, 18 | currentUserId, 19 | userProfile, 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /src/composables/useResponsive.ts: -------------------------------------------------------------------------------- 1 | import { computed } from 'vue'; 2 | import vuetify from '@/plugins/vuetify'; 3 | 4 | /** 5 | * Composable for responsive breakpoint checks. 6 | * Replaces CVue's isDesktop property. 7 | * 8 | * Note: In Vue 2.7 with Vuetify 2, we access the breakpoint through 9 | * the vuetify instance. In Vue 3 with Vuetify 3, this will use useDisplay(). 10 | */ 11 | export function useResponsive() { 12 | const isDesktop = computed(() => vuetify.framework.breakpoint.mdAndUp); 13 | const isMobile = computed(() => vuetify.framework.breakpoint.smAndDown); 14 | const breakpoint = computed(() => vuetify.framework.breakpoint); 15 | 16 | return { 17 | isDesktop, 18 | isMobile, 19 | breakpoint, 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /src/components/base/BaseCard.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 32 | -------------------------------------------------------------------------------- /src/components/widgets/DiffView.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 27 | -------------------------------------------------------------------------------- /src/apiWrapper.ts: -------------------------------------------------------------------------------- 1 | import { AxiosError } from 'axios'; 2 | import { dispatchCaptureApiErrorWithErrorHandler } from '@/store/main/actions'; 3 | import { CVue } from '@/common'; 4 | import axios from 'axios'; 5 | import { authHeaders } from '@/utils'; 6 | 7 | export class APIWrapper { 8 | private ctx: CVue; 9 | constructor(ctx: CVue) { 10 | this.ctx = ctx; 11 | } 12 | async get(url: string, cb: (T) => void) { 13 | await dispatchCaptureApiErrorWithErrorHandler(this.ctx.$store, { 14 | action: async () => { 15 | const ret = await axios.get(url, authHeaders(this.ctx.token)); 16 | cb(ret.data); 17 | }, 18 | errorFilter: (err: AxiosError) => { 19 | return this.ctx.commitErrMsg(err) !== null; 20 | }, 21 | }); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noImplicitAny": false, 4 | "target": "esnext", 5 | "module": "esnext", 6 | "strict": true, 7 | "jsx": "preserve", 8 | "importHelpers": true, 9 | "moduleResolution": "node", 10 | "experimentalDecorators": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "sourceMap": true, 14 | "baseUrl": ".", 15 | "types": ["webpack-env", "jest", "vuetify", "node"], 16 | "paths": { 17 | "@/*": ["src/*"] 18 | }, 19 | "lib": ["esnext", "dom", "dom.iterable", "scripthost"], 20 | "resolveJsonModule": true 21 | }, 22 | "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "tests/**/*.ts", "tests/**/*.tsx"], 23 | "exclude": ["node_modules"] 24 | } 25 | -------------------------------------------------------------------------------- /src/components/UserNameHeadline.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 27 | -------------------------------------------------------------------------------- /src/api/forms.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { apiUrl } from '@/env'; 3 | import { IForm, IFormCreate, IFormResponse, IFormResponseCreate } from '@/interfaces'; 4 | import { authHeaders } from '@/utils'; 5 | 6 | export const apiForm = { 7 | async createForm(token: string, payload: IFormCreate) { 8 | return axios.post(`${apiUrl}/forms/`, payload, authHeaders(token)); 9 | }, 10 | async getForms(token: string) { 11 | return axios.get(`${apiUrl}/forms/`, authHeaders(token)); 12 | }, 13 | async getForm(token: string, uuid: string) { 14 | return axios.get(`${apiUrl}/forms/${uuid}`, authHeaders(token)); 15 | }, 16 | async submitFormRespnse(token: string, payload: IFormResponseCreate) { 17 | return axios.post(`${apiUrl}/form-responses/`, payload, authHeaders(token)); 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /src/components/SiteJoinConditions.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 27 | -------------------------------------------------------------------------------- /src/api/topic.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { apiUrl } from '@/env'; 3 | import { authHeaders } from '@/utils'; 4 | import { IQuestionPreview, ITopic, ITopicCreate, ITopicUpdate } from '@/interfaces'; 5 | 6 | export const apiTopic = { 7 | async createTopic(token: string, payload: ITopicCreate) { 8 | return axios.post(`${apiUrl}/topics/`, payload, authHeaders(token)); 9 | }, 10 | async getTopic(topicUUID: string) { 11 | return axios.get(`${apiUrl}/topics/${topicUUID}`); 12 | }, 13 | async updateTopic(token: string, topicUUID: string, payload: ITopicUpdate) { 14 | return axios.put(`${apiUrl}/topics/${topicUUID}`, payload, authHeaders(token)); 15 | }, 16 | async getQuestionsOfTopic(token: string, topicUUID: string) { 17 | return axios.get( 18 | `${apiUrl}/topics/${topicUUID}/questions/`, 19 | authHeaders(token) 20 | ); 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /src/registerServiceWorker.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-console */ 2 | 3 | import { register } from 'register-service-worker'; 4 | 5 | if (process.env.NODE_ENV === 'production') { 6 | register(`${process.env.BASE_URL}service-worker.js`, { 7 | ready() { 8 | console.log( 9 | 'App is being served from cache by a service worker.\n' + 10 | 'For more details, visit https://goo.gl/AFskqB' 11 | ); 12 | }, 13 | cached() { 14 | console.log('Content has been cached for offline use.'); 15 | }, 16 | updated(registration) { 17 | console.log('New content is available; please refresh.'); 18 | document.dispatchEvent(new CustomEvent('swUpdated', { detail: registration })); 19 | }, 20 | offline() { 21 | console.log('No internet connection found. App is running in offline mode.'); 22 | }, 23 | error(error) { 24 | console.error('Error during service worker registration:', error); 25 | }, 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /src/components/Avatar.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 35 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs = { 3 | #nixpkgs-unstable.url = "github:NixOS/nixpkgs/nixos-unstable"; 4 | nixpkgs.url = "github:nixos/nixpkgs/nixos-25.05"; 5 | #flake-utils.url = "github:numtide/flake-utils"; 6 | }; 7 | outputs = { self, nixpkgs }: { 8 | devShell.x86_64-linux = 9 | let 10 | pkgs = nixpkgs.legacyPackages.x86_64-linux; 11 | #pkgs_unstable = nixpkgs-unstable.legacyPackages.x86_64-linux; 12 | in pkgs.mkShell { 13 | LOCALE_ARCHIVE = if pkgs.stdenv.isLinux then "${pkgs.glibcLocales}/lib/locale/locale-archive" else ""; 14 | buildInputs = [ 15 | pkgs.python312 16 | pkgs.yarn-berry 17 | # berry 4.5.0 had trouble installing sentry/cli 2025-Feb-20 18 | pkgs.nodejs_22 19 | pkgs.python312Packages.distutils 20 | 21 | 22 | 23 | pkgs.nodePackages.serve 24 | 25 | pkgs.mkcert 26 | ]; 27 | }; 28 | }; 29 | 30 | 31 | 32 | 33 | } 34 | -------------------------------------------------------------------------------- /src/views/showcase/ExampleSubmissionPreview.vue: -------------------------------------------------------------------------------- 1 | 16 | 29 | -------------------------------------------------------------------------------- /src/components/CommentPreview.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 37 | -------------------------------------------------------------------------------- /src/components/icons/ZhihuIcon.vue: -------------------------------------------------------------------------------- 1 | 17 | -------------------------------------------------------------------------------- /src/components/widgets/ContribGraphs.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 45 | -------------------------------------------------------------------------------- /src/components/TopicSearch.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 52 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: CI 4 | 5 | on: 6 | push: 7 | branches: [master, dev, preview] 8 | pull_request: 9 | branches: [master, preview] 10 | 11 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 12 | jobs: 13 | # This workflow contains a single job called "build" 14 | build: 15 | # The type of runner that the job will run on 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - name: Setup Node.js environment 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: 22.16.0 25 | 26 | - name: Enable Corepack 27 | run: corepack enable 28 | 29 | - name: Build 30 | run: | 31 | source example.env 32 | ./cloudflare.build.sh 33 | - name: Check linting etc. 34 | run: | 35 | yarn --version 36 | #yarn run check:eslint 37 | #yarn run check:prettier 38 | - name: Unit tests 39 | run: | 40 | yarn --version 41 | #yarn run test:unit 42 | - name: CSS Lint 43 | run: npm run lint:css || true 44 | -------------------------------------------------------------------------------- /src/components/home/UserSubmissionsRankedFeed.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 37 | -------------------------------------------------------------------------------- /src/views/main/Search.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 45 | -------------------------------------------------------------------------------- /src/components/UIStyleControllers.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 40 | -------------------------------------------------------------------------------- /src/components/EduExp.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 41 | -------------------------------------------------------------------------------- /src/api/discovery.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { IAnswerPreview, IQuestionPreview, IUserPreview } from '@/interfaces'; 3 | import { apiUrl } from '@/env'; 4 | import { authHeaders, authHeadersWithParams } from '@/utils'; 5 | 6 | export const apiDiscovery = { 7 | async getPinnedQuestions() { 8 | return axios.get(`${apiUrl}/discovery/pinned-questions/`); 9 | }, 10 | async getPendingQuestions(token: string) { 11 | return axios.get( 12 | `${apiUrl}/discovery/pending-questions/`, 13 | authHeaders(token) 14 | ); 15 | }, 16 | async getInterestingQuestions(token: string) { 17 | return axios.get( 18 | `${apiUrl}/discovery/interesting-questions/`, 19 | authHeaders(token) 20 | ); 21 | }, 22 | async getInterestingUsers(token: string) { 23 | return axios.get(`${apiUrl}/discovery/interesting-users/`, authHeaders(token)); 24 | }, 25 | async getFeaturedAnswers(token: string, skip: number, limit: number) { 26 | const params = new URLSearchParams(); 27 | params.append('skip', skip.toString()); 28 | params.append('limit', limit.toString()); 29 | return axios.get( 30 | `${apiUrl}/discovery/featured-answers/`, 31 | authHeadersWithParams(token, params) 32 | ); 33 | }, 34 | }; 35 | -------------------------------------------------------------------------------- /src/components/base/RotationCard.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 40 | -------------------------------------------------------------------------------- /src/env.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | 3 | export const env = process.env.VUE_APP_ENV; 4 | 5 | const envApi = `https://${process.env.VUE_APP_API}`; 6 | const envWs = `wss://${process.env.VUE_APP_API}`; 7 | 8 | const apiVersionSuffix = '/api/v1'; 9 | 10 | export const apiUrl = envApi + apiVersionSuffix; 11 | export const wsUrl = envWs + apiVersionSuffix; 12 | export const appName = process.env.VUE_APP_NAME; 13 | export const sentryDSN = process.env.VUE_APP_SENTRY_DSN; 14 | export const enableCaptcha = false; // Turn off front end rate control - 2024 Dec 06 15 | export const lambdaUrlBase = process.env.VUE_APP_LAMBDA; 16 | 17 | export const defaultSiteUuid = '377cjuUDLMMdpH7dWutU'; 18 | 19 | /** 20 | * Information about this build. 21 | */ 22 | export const buildInfo = { 23 | /** Git commit ID */ 24 | commitHash: process.env.VUE_APP_GIT_COMMIT as string, 25 | /** Git commit short ID */ 26 | commitHashShort: (process.env.VUE_APP_GIT_COMMIT as string).substring(0, 8), 27 | /** Git branch */ 28 | branch: process.env.VUE_APP_GIT_BRANCH as string, 29 | /** Git commit time, undefined if not available */ 30 | commitTime: process.env.VUE_APP_GIT_COMMIT_TIME 31 | ? dayjs(process.env.VUE_APP_GIT_COMMIT_TIME) 32 | : undefined, 33 | /** Git tags applied to this commit */ 34 | tags: (process.env.VUE_APP_GIT_TAGS as string).split('\n'), 35 | }; 36 | -------------------------------------------------------------------------------- /src/api/comment.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { apiUrl } from '@/env'; 3 | import { authHeaders } from '@/utils'; 4 | import { 5 | IComment, 6 | ICommentCreate, 7 | ICommentUpdate, 8 | ICommentUpvotes, 9 | IGenericResponse, 10 | } from '@/interfaces'; 11 | 12 | export const apiComment = { 13 | async postComment(token: string, payload: ICommentCreate) { 14 | return axios.post(`${apiUrl}/comments/`, payload, authHeaders(token)); 15 | }, 16 | async updateComment(token: string, commentUUID: string, payload: ICommentUpdate) { 17 | return axios.put(`${apiUrl}/comments/${commentUUID}`, payload, authHeaders(token)); 18 | }, 19 | async getUpvotes(token: string, uuid: string) { 20 | return axios.get(`${apiUrl}/comments/${uuid}/upvotes/`, authHeaders(token)); 21 | }, 22 | async deleteComment(token: string, commentUUID: string) { 23 | return axios.delete(`${apiUrl}/comments/${commentUUID}`, authHeaders(token)); 24 | }, 25 | async upvote(token: string, uuid: string) { 26 | return axios.post( 27 | `${apiUrl}/comments/${uuid}/upvotes/`, 28 | null, 29 | authHeaders(token) 30 | ); 31 | }, 32 | async cancelUpvote(token: string, uuid: string) { 33 | return axios.delete(`${apiUrl}/comments/${uuid}/upvotes/`, authHeaders(token)); 34 | }, 35 | }; 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Chafan 2.0 PWA 2 | 3 | 经过代码重构以后,在本地搭建前端开发环境变得非常简单(不超过三分钟)。非常欢迎有兴趣的朋友参与 Chafan 的开发。所有的 PR 都受欢迎 - 小到改一处多余的空格,或是升级某个依赖的 yarn 包也很欢迎。如果您有兴趣提交一个大的 PR 的话,强烈建议您先联系狗管理。 4 | 5 | ## Local development setup 6 | 7 | **Step 1: Install dependencies** 8 | 9 | ``` 10 | nix develop 11 | ``` 12 | 13 | 不用 Nix 的用户,使用其他方式安装 `npm` `yarn` `serve` `mkcert` 即可 14 | 15 | **Step 2: Set environment** 16 | 17 | ``` 18 | export VUE_APP_API=api.$VUE_APP_HOST 19 | export VUE_APP_NAME=ChaFan 20 | export VUE_APP_ENV=staging 21 | ``` 22 | 23 | 没错,得益于前后端分离的架构,在前端开发时鼓励大家直接使用 production ChaFan API。 24 | 25 | **Step 3: Yarn Build** 26 | ``` 27 | ./cloudflare.build.sh --loose 28 | ``` 29 | 30 | **Step 4: Generate Cert** 31 | 32 | ``` 33 | mkcert localhost 34 | ``` 35 | 36 | 37 | **Step 5: Serve locally** 38 | 39 | ``` 40 | serve -l 8080 -s dist --ssl-cert ./localhost.pem --ssl-key ./localhost-key.pem 41 | ``` 42 | 43 | **Step 6: Test in browser** 44 | ``` 45 | https://127.0.0.1:8080 46 | ``` 47 | 48 | 这个域名在茶饭 backend 的 CORS 白名单内 49 | 50 | ## Deploy with Cloudflare Workers Pages 51 | `chafan-dev/chafan-pwa` 是用于开发和测试的 `public repo`. `chai-inu/chafan-pwa-deploy` 是一个 `private repo` 和管理员的 Cloudflare 账号绑定。 52 | 53 | 1. 所有的 Pull Request 都会被合并到 `public/master` 54 | 2. Fast-forward 到 `private/preview`, 会自动部署到 `preview.cha.fan` 55 | 3. Fast-forward 到 `private/master`, 会自动部署到 `cha.fan` 56 | 4. `private/dev` 用于后端开发。它会被部署到 `dev.cha.fan`, 使用后端 `api_dev.cha.fan` 57 | 58 | 59 | -------------------------------------------------------------------------------- /src/components/base/RotationList.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 45 | -------------------------------------------------------------------------------- /cloudflare.build.sh: -------------------------------------------------------------------------------- 1 | set -xe 2 | 3 | YARN_VERSION="4.9.1" 4 | #NODE_VERSION="18.20.6" #Nix 5 | NODE_VERSION="22.16.0" # Cloudflare Worker v2 6 | 7 | if [[ $(yarn --version) != "$YARN_VERSION" ]]; then 8 | yarn set version "$YARN_VERSION" 9 | yarn --version 10 | fi 11 | if [[ $(node --version) != "v$NODE_VERSION" ]]; then 12 | echo "Node version mismatch, skip for now" 13 | node --version 14 | fi 15 | 16 | 17 | export VUE_APP_CDN_DOMAIN=cdn.jsdelivr.net 18 | export VUE_APP_HOST=cha.fan 19 | 20 | if [ -z "${VUE_APP_NAME-}" ]; then 21 | echo "Must provide var environment variable. Exiting...." 22 | exit 1 23 | fi 24 | if [ -z "${VUE_APP_API-}" ]; then 25 | echo "Must provide var environment variable. Exiting...." 26 | exit 1 27 | fi 28 | if [ -z "${VUE_APP_ENV-}" ]; then 29 | echo "Must provide var environment variable. Exiting...." 30 | exit 1 31 | fi 32 | 33 | 34 | 35 | 36 | if [[ "$1" == "--loose" ]]; then 37 | yarn install 38 | yarn run build 39 | else 40 | # Make Cloudflare Worker happy: Cloudflare would first use yarn-berry 3.5.0 to 41 | # read yarn.lock, then complain the yarn.lock has been modified 42 | # Use this trick to skip CF's check, and put real work inside this bash script 43 | 44 | yarn config get enableImmutableInstalls 45 | #yarn install --immutable 46 | yarn install --frozen-lockfile 47 | yarn run build 48 | fi 49 | 50 | # https://developers.cloudflare.com/pages/configuration/build-image/ 51 | -------------------------------------------------------------------------------- /src/components/image/LightboxGroup.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 46 | -------------------------------------------------------------------------------- /src/api/activity.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { apiUrl } from '@/env'; 3 | import { IFeedSequence, IGenericResponse, IUpdateOrigins, IUserFeedSettings } from '@/interfaces'; 4 | import { authHeaders, authHeadersWithParams } from '@/utils'; 5 | 6 | export const apiActivity = { 7 | async getFeedSequence( 8 | token: string, 9 | payload: { 10 | limit: number; 11 | before_activity_id?: number; 12 | subjectUserUUID?: number; 13 | random?: boolean; 14 | } 15 | ) { 16 | const params = new URLSearchParams(); 17 | params.set('limit', payload.limit.toString()); 18 | if (payload.before_activity_id !== undefined) { 19 | params.set('before_activity_id', payload.before_activity_id.toString()); 20 | } 21 | if (payload.subjectUserUUID !== undefined) { 22 | params.set('subject_user_uuid', payload.subjectUserUUID.toString()); 23 | } 24 | if (payload.random !== undefined) { 25 | params.set('random', payload.random.toString()); 26 | } 27 | params.set('full_answers', 'true'); 28 | return axios.get(`${apiUrl}/activities/`, authHeadersWithParams(token, params)); 29 | }, 30 | async getSettings(token: string) { 31 | return axios.get(`${apiUrl}/activities/settings/`, authHeaders(token)); 32 | }, 33 | async updateOrigins(token: string, payload: IUpdateOrigins) { 34 | return axios.put( 35 | `${apiUrl}/activities/settings/blocked-origins/`, 36 | payload, 37 | authHeaders(token) 38 | ); 39 | }, 40 | }; 41 | -------------------------------------------------------------------------------- /src/components/Viewer.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 44 | -------------------------------------------------------------------------------- /src/api2.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { apiUrl } from '@/env'; 3 | import { 4 | ICoinPayment, 5 | IFeedback, 6 | IGenericResponse, 7 | INotification, 8 | IUploadedImage, 9 | IUserInvite, 10 | IWsAuthResponse, 11 | } from './interfaces'; 12 | import { authHeaders, authHeadersFormData } from './utils'; 13 | 14 | // NOTE: This is because webpack seems having weird bug when I put too many things in api.ts 15 | export const api2 = { 16 | async getCoinPayments(token: string) { 17 | return axios.get(`${apiUrl}/coin-payments/`, authHeaders(token)); 18 | }, 19 | async getReadNotifications(token: string) { 20 | return axios.get(`${apiUrl}/notifications/read/`, authHeaders(token)); 21 | }, 22 | async inviteUser(token: string, payload: IUserInvite) { 23 | return axios.post(`${apiUrl}/users/invite`, payload, authHeaders(token)); 24 | }, 25 | async uploadFile(token: string, payload: FormData) { 26 | return axios.post( 27 | `${apiUrl}/upload/images/`, 28 | payload, 29 | authHeadersFormData(token) 30 | ); 31 | }, 32 | async getWsToken(token: string) { 33 | return axios.post(`${apiUrl}/ws/token`, null, authHeaders(token)); 34 | }, 35 | async uploadFeedback(token: string, payload: FormData) { 36 | return axios.post( 37 | `${apiUrl}/feedbacks/`, 38 | payload, 39 | authHeadersFormData(token) 40 | ); 41 | }, 42 | async getFeedbacks(token: string) { 43 | return axios.get(`${apiUrl}/feedbacks/`, authHeadersFormData(token)); 44 | }, 45 | }; 46 | -------------------------------------------------------------------------------- /src/components/widgets/VerificationCodeBtn.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 54 | -------------------------------------------------------------------------------- /src/components/ArticlePreview.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 51 | -------------------------------------------------------------------------------- /src/components/home/UserAgreement.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 49 | -------------------------------------------------------------------------------- /src/views/main/dashboard/Feedback.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 58 | -------------------------------------------------------------------------------- /src/components/UserLink.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 58 | -------------------------------------------------------------------------------- /src/components/question/QuestionUpvotes.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 55 | -------------------------------------------------------------------------------- /src/components/submission/SubmissionUpvotes.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 55 | -------------------------------------------------------------------------------- /src/components/ActivitySubject.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 54 | -------------------------------------------------------------------------------- /src/plugins/vee-validate.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import { extend, localize, ValidationObserver, ValidationProvider } from 'vee-validate'; 3 | import { email, min, required } from 'vee-validate/dist/rules'; 4 | import en from 'vee-validate/dist/locale/en.json'; 5 | import zh_CN from 'vee-validate/dist/locale/zh_CN.json'; 6 | import { URLRegex, PasswordRegex, PhoneNumberRegex } from '@/common'; 7 | 8 | extend('password1', { 9 | params: ['target'], 10 | validate(value, args: Record) { 11 | return value === args.target; 12 | }, 13 | message: '密码不一致', 14 | }); 15 | 16 | extend('password', { 17 | validate(value: string) { 18 | return value.match(PasswordRegex) !== null; 19 | }, 20 | message: '密码必须至少8位,由数字、字母或者特殊符号组成', 21 | }); 22 | 23 | extend('url', { 24 | validate(value: string) { 25 | return value.match(URLRegex) !== null; 26 | }, 27 | message: '无效的 URL', 28 | }); 29 | 30 | extend('phone_number_e164', { 31 | validate(value: string) { 32 | return value.match(PhoneNumberRegex) !== null; 33 | }, 34 | message: '无效格式,有效格式的例子:+1222333444, +8611122223333', 35 | }); 36 | 37 | extend('id', { 38 | validate(value: string) { 39 | return value.match(/^[\w-]+$/g) !== null; 40 | }, 41 | message: 'id 中仅允许使用字母数字、下划线和"-",区分大小写', 42 | }); 43 | 44 | // No message specified. 45 | extend('email', email); 46 | extend('required', required); 47 | extend('min', min); 48 | 49 | Vue.component('ValidationProvider', ValidationProvider); 50 | Vue.component('ValidationObserver', ValidationObserver); 51 | 52 | localize({ 53 | en, 54 | zh_CN, 55 | }); 56 | 57 | localize({ 58 | en: { 59 | names: { 60 | password: 'Password', 61 | email: 'Email', 62 | phonenumber: 'Phone number', 63 | confirm: 'Password confirmation', 64 | }, 65 | }, 66 | zh_CN: { 67 | names: { 68 | password: '密码', 69 | email: '电子邮件地址', 70 | phonenumber: '电话号', 71 | confirm: '密码确认', 72 | }, 73 | }, 74 | }); 75 | -------------------------------------------------------------------------------- /src/views/main/Chat.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 63 | -------------------------------------------------------------------------------- /src/components/ShareCardButton.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 65 | -------------------------------------------------------------------------------- /src/components/SiteSearch.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 85 | -------------------------------------------------------------------------------- /src/imagelib.ts: -------------------------------------------------------------------------------- 1 | export interface IResizeImageInput { 2 | maxSize: number; 3 | file: Blob; 4 | } 5 | 6 | export interface IResizedImage { 7 | dataUrl: string; 8 | blob: Blob; 9 | } 10 | 11 | export const resizeImage = (input: IResizeImageInput) => { 12 | const file = input.file; 13 | const maxSize = input.maxSize; 14 | const reader = new FileReader(); 15 | const image = new Image(); 16 | const canvas = document.createElement('canvas'); 17 | 18 | const dataURItoBlob = (dataURI: string) => { 19 | const bytes = 20 | dataURI.split(',')[0].indexOf('base64') >= 0 21 | ? atob(dataURI.split(',')[1]) 22 | : unescape(dataURI.split(',')[1]); 23 | const mime = dataURI.split(',')[0].split(':')[1].split(';')[0]; 24 | const max = bytes.length; 25 | const ia = new Uint8Array(max); 26 | for (let i = 0; i < max; i++) { 27 | ia[i] = bytes.charCodeAt(i); 28 | } 29 | return new Blob([ia], { type: mime }); 30 | }; 31 | 32 | const resize = () => { 33 | let width = image.width; 34 | let height = image.height; 35 | 36 | if (width > height) { 37 | if (width > maxSize) { 38 | height *= maxSize / width; 39 | width = maxSize; 40 | } 41 | } else { 42 | if (height > maxSize) { 43 | width *= maxSize / height; 44 | height = maxSize; 45 | } 46 | } 47 | 48 | canvas.width = width; 49 | canvas.height = height; 50 | canvas.getContext('2d')?.drawImage(image, 0, 0, width, height); 51 | const dataUrl = canvas.toDataURL('image/jpeg'); 52 | return { 53 | dataUrl, 54 | blob: dataURItoBlob(dataUrl), 55 | }; 56 | }; 57 | 58 | return new Promise( 59 | (ok: (resized: IResizedImage) => void, no: (error: Error) => void) => { 60 | if (!file.type.match(/image.*/)) { 61 | no(new Error('Not an image')); 62 | return; 63 | } 64 | 65 | reader.onload = (readerEvent: any) => { 66 | image.onload = () => ok(resize()); 67 | image.src = readerEvent.target.result; 68 | }; 69 | reader.readAsDataURL(file); 70 | } 71 | ); 72 | }; 73 | -------------------------------------------------------------------------------- /src/store/main/getters.ts: -------------------------------------------------------------------------------- 1 | import { MainState } from './state'; 2 | import { getStoreAccessors } from 'typesafe-vuex'; 3 | import { State } from '../state'; 4 | 5 | export const getters = { 6 | hasModeratedSites: (state: MainState) => { 7 | return state.moderated_sites && state.moderated_sites.length > 0; 8 | }, 9 | loginError: (state: MainState) => state.logInError, 10 | dashboardShowDrawer: (state: MainState) => state.dashboardShowDrawer, 11 | dashboardMiniDrawer: (state: MainState) => state.dashboardMiniDrawer, 12 | userProfile: (state: MainState) => state.userProfile, 13 | moderatedSites: (state: MainState) => state.moderated_sites, 14 | token: (state: MainState) => state.token, 15 | topBanner: (state: MainState) => state.topBanner, 16 | localePreference: (state: MainState) => state.userProfile?.locale_preference, 17 | isLoggedIn: (state: MainState) => state.isLoggedIn, 18 | showLoginPrompt: (state: MainState) => state.showLoginPrompt, 19 | firstNotification: (state: MainState) => state.notifications.length > 0 && state.notifications[0], 20 | workingDraft: (state: MainState) => state.workingDraft, 21 | narrowUI: (state: MainState) => state.narrowUI, 22 | theme: (state: MainState) => state.theme, 23 | }; 24 | 25 | const { read } = getStoreAccessors(''); 26 | 27 | export const readDashboardMiniDrawer = read(getters.dashboardMiniDrawer); 28 | export const readDashboardShowDrawer = read(getters.dashboardShowDrawer); 29 | export const readHasModeratedSites = read(getters.hasModeratedSites); 30 | export const readIsLoggedIn = read(getters.isLoggedIn); 31 | export const readShowLoginPrompt = read(getters.showLoginPrompt); 32 | export const readLoginError = read(getters.loginError); 33 | export const readToken = read(getters.token); 34 | export const readUserProfile = read(getters.userProfile); 35 | export const readModeratedSites = read(getters.moderatedSites); 36 | export const readFirstNotification = read(getters.firstNotification); 37 | export const readWorkingDraft = read(getters.workingDraft); 38 | export const readNarrowUI = read(getters.narrowUI); 39 | export const readTheme = read(getters.theme); 40 | -------------------------------------------------------------------------------- /src/composables/useNotification.ts: -------------------------------------------------------------------------------- 1 | import { commitAddNotification } from '@/store/main/mutations'; 2 | import { IGenericResponse } from '@/interfaces'; 3 | import { AxiosError } from 'axios'; 4 | import store from '@/store'; 5 | 6 | /** 7 | * Composable for notification helpers. 8 | * Replaces CVue's expectOkAndCommitMsg and commitErrMsg methods. 9 | */ 10 | export function useNotification() { 11 | /** 12 | * Shows a success notification 13 | */ 14 | function notifySuccess(content: string) { 15 | commitAddNotification(store, { 16 | content, 17 | color: 'success', 18 | }); 19 | } 20 | 21 | /** 22 | * Shows an error notification 23 | */ 24 | function notifyError(content: string) { 25 | commitAddNotification(store, { 26 | content, 27 | color: 'error', 28 | }); 29 | } 30 | 31 | /** 32 | * Shows a warning notification 33 | */ 34 | function notifyWarning(content: string) { 35 | commitAddNotification(store, { 36 | content, 37 | color: 'warning', 38 | }); 39 | } 40 | 41 | /** 42 | * Shows an info notification 43 | */ 44 | function notifyInfo(content: string) { 45 | commitAddNotification(store, { 46 | content, 47 | color: 'info', 48 | }); 49 | } 50 | 51 | /** 52 | * Handles a generic response - shows success message or error 53 | * Replaces CVue's expectOkAndCommitMsg method 54 | */ 55 | function expectOkAndCommitMsg(response: IGenericResponse, successMsg: string) { 56 | if (response.success) { 57 | notifySuccess(successMsg); 58 | } else { 59 | notifyError('服务器错误'); 60 | } 61 | } 62 | 63 | /** 64 | * Handles an Axios error and shows notification 65 | * Replaces CVue's commitErrMsg method 66 | */ 67 | function commitErrMsg(err: AxiosError): string | null { 68 | if (err.response && err.message) { 69 | notifyWarning(err.message); 70 | return err.message; 71 | } 72 | return null; 73 | } 74 | 75 | return { 76 | notifySuccess, 77 | notifyError, 78 | notifyWarning, 79 | notifyInfo, 80 | expectOkAndCommitMsg, 81 | commitErrMsg, 82 | }; 83 | } 84 | -------------------------------------------------------------------------------- /src/components/SiteBtn.vue: -------------------------------------------------------------------------------- 1 | 54 | 55 | 85 | -------------------------------------------------------------------------------- /src/components/home/UserWelcome.vue: -------------------------------------------------------------------------------- 1 | 58 | 71 | -------------------------------------------------------------------------------- /eslintrc.js.bak: -------------------------------------------------------------------------------- 1 | /* 2 | 👋 Hi! This file was autogenerated by tslint-to-eslint-config. 3 | https://github.com/typescript-eslint/tslint-to-eslint-config 4 | 5 | It represents the closest reasonable ESLint configuration to this 6 | project's original TSLint configuration. 7 | 8 | We recommend eventually switching this configuration to extend from 9 | the recommended rulesets in typescript-eslint. 10 | https://github.com/typescript-eslint/tslint-to-eslint-config/blob/master/docs/FAQs.md 11 | 12 | Happy linting! 💖 13 | */ 14 | module.exports = { 15 | env: { 16 | browser: true, 17 | es6: true, 18 | node: true, 19 | }, 20 | 21 | extends: [ 22 | 'eslint:recommended', 23 | 'plugin:@typescript-eslint/recommended', 24 | 'plugin:vue/essential', 25 | '@vue/typescript/recommended', 26 | // '@vue/prettier', 27 | // '@vue/prettier/@typescript-eslint', 28 | 29 | // TODO: Add more recommended rules 30 | // 'plugin:@typescript-eslint/recommended-requiring-type-checking', 31 | ], 32 | 33 | parser: 'vue-eslint-parser', 34 | 35 | parserOptions: { 36 | parser: '@typescript-eslint/parser', 37 | project: 'tsconfig.json', 38 | sourceType: 'module', 39 | ecmaVersion: 2020, 40 | extraFileExtensions: ['.vue'], 41 | }, 42 | 43 | plugins: ['@typescript-eslint'], 44 | 45 | rules: { 46 | '@typescript-eslint/no-unsafe-call': 'off', 47 | '@typescript-eslint/no-unsafe-member-access': 'off', 48 | '@typescript-eslint/explicit-module-boundary-types': 'off', 49 | '@typescript-eslint/no-empty-interface': 'warn', 50 | '@typescript-eslint/no-explicit-any': 'off', 51 | '@typescript-eslint/no-non-null-assertion': 'off', 52 | 'no-useless-escape': 'warn', 53 | '@typescript-eslint/no-empty-function': 'warn', 54 | 'no-control-regex': 'warn', 55 | '@typescript-eslint/no-inferrable-types': 'off', 56 | 'no-empty': 'off', 57 | 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 58 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 59 | }, 60 | 61 | root: true, 62 | 63 | overrides: [ 64 | { 65 | files: ['**/__tests__/*.{j,t}s?(x)', '**/tests/unit/**/*.spec.{j,t}s?(x)'], 66 | env: { 67 | jest: true, 68 | }, 69 | }, 70 | ], 71 | }; 72 | -------------------------------------------------------------------------------- /src/components/CommentCard.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 62 | -------------------------------------------------------------------------------- /src/api/search.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { apiUrl } from '@/env'; 3 | import { 4 | IAnswerPreview, 5 | IArticlePreview, 6 | IQuestionPreview, 7 | ISite, 8 | ISubmission, 9 | ITopic, 10 | IUserPreview, 11 | } from '@/interfaces'; 12 | import { authHeadersWithParams } from '@/utils'; 13 | 14 | export const apiSearch = { 15 | async searchUsers(token: string, fragment: string) { 16 | const params = new URLSearchParams(); 17 | params.append('q', fragment); 18 | return axios.get( 19 | `${apiUrl}/search/users/`, 20 | authHeadersWithParams(token, params) 21 | ); 22 | }, 23 | async searchTopics(token: string, fragment: string) { 24 | const params = new URLSearchParams(); 25 | params.append('q', fragment); 26 | return axios.get(`${apiUrl}/search/topics/`, authHeadersWithParams(token, params)); 27 | }, 28 | async searchQuestions(token: string, fragment: string) { 29 | const params = new URLSearchParams(); 30 | params.append('q', fragment); 31 | return axios.get( 32 | `${apiUrl}/search/questions/`, 33 | authHeadersWithParams(token, params) 34 | ); 35 | }, 36 | async searchSites(token: string, fragment: string) { 37 | const params = new URLSearchParams(); 38 | params.append('q', fragment); 39 | return axios.get(`${apiUrl}/search/sites/`, authHeadersWithParams(token, params)); 40 | }, 41 | async searchSubmissions(token: string, fragment: string) { 42 | const params = new URLSearchParams(); 43 | params.append('q', fragment); 44 | return axios.get( 45 | `${apiUrl}/search/submissions/`, 46 | authHeadersWithParams(token, params) 47 | ); 48 | }, 49 | async searchAnswers(token: string, fragment: string) { 50 | const params = new URLSearchParams(); 51 | params.append('q', fragment); 52 | return axios.get( 53 | `${apiUrl}/search/answers/`, 54 | authHeadersWithParams(token, params) 55 | ); 56 | }, 57 | async searchArticles(token: string, fragment: string) { 58 | const params = new URLSearchParams(); 59 | params.append('q', fragment); 60 | return axios.get( 61 | `${apiUrl}/search/articles/`, 62 | authHeadersWithParams(token, params) 63 | ); 64 | }, 65 | }; 66 | -------------------------------------------------------------------------------- /src/api/site.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { 3 | ICreateSiteResponse, 4 | IGenericResponse, 5 | IQuestionPreview, 6 | ISite, 7 | ISiteApplicationResponse, 8 | ISiteCreate, 9 | ISiteUpdate, 10 | ISubmission, 11 | IWebhook, 12 | } from '@/interfaces'; 13 | import { apiUrl } from '@/env'; 14 | import { authHeaders, authHeadersWithParams } from '@/utils'; 15 | 16 | export const apiSite = { 17 | async getSite(subdomain: string) { 18 | return axios.get(`${apiUrl}/sites/${subdomain}`); 19 | }, 20 | async getSiteQuestions(_token: string, siteUUID: string, skip: number, limit: number) { 21 | const params = new URLSearchParams(); 22 | params.append('skip', skip.toString()); 23 | params.append('limit', limit.toString()); 24 | return axios.get( 25 | `${apiUrl}/sites/${siteUUID}/questions/?skip=${skip}&limit=${limit}` 26 | ); 27 | }, 28 | async updateSiteConfig(token: string, siteUUID: string, payload: ISiteUpdate) { 29 | return axios.put(`${apiUrl}/sites/${siteUUID}/config`, payload, authHeaders(token)); 30 | }, 31 | async getSiteSubmissions(token: string, uuid: string, skip: number, limit: number) { 32 | const params = new URLSearchParams(); 33 | params.append('skip', skip.toString()); 34 | params.append('limit', limit.toString()); 35 | return axios.get( 36 | `${apiUrl}/sites/${uuid}/submissions/`, 37 | authHeadersWithParams(token, params) 38 | ); 39 | }, 40 | async getWebhooks(token: string, uuid: string) { 41 | return axios.get(`${apiUrl}/sites/${uuid}/webhooks/`, authHeaders(token)); 42 | }, 43 | async getRelatedSites(uuid: string) { 44 | return axios.get(`${apiUrl}/sites/${uuid}/related/`); 45 | }, 46 | async createSite(token: string, payload: ISiteCreate) { 47 | return axios.post(`${apiUrl}/sites/`, payload, authHeaders(token)); 48 | }, 49 | async getSiteApply(token: string, siteUUID: string) { 50 | return axios.get( 51 | `${apiUrl}/sites/${siteUUID}/apply`, 52 | authHeaders(token) 53 | ); 54 | }, 55 | async applySite(token: string, siteUUID: string) { 56 | return axios.post( 57 | `${apiUrl}/sites/${siteUUID}/apply`, 58 | null, 59 | authHeaders(token) 60 | ); 61 | }, 62 | async leaveSite(token: string, siteUUID: string) { 63 | return axios.delete( 64 | `${apiUrl}/sites/${siteUUID}/membership`, 65 | authHeaders(token) 66 | ); 67 | }, 68 | }; 69 | -------------------------------------------------------------------------------- /src/components/Invite.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 78 | -------------------------------------------------------------------------------- /src/components/Upvote.vue: -------------------------------------------------------------------------------- 1 | 41 | 75 | 76 | 82 | -------------------------------------------------------------------------------- /src/views/main/CreateArticleColumn.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 70 | -------------------------------------------------------------------------------- /src/views/main/ArticleColumn.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 73 | -------------------------------------------------------------------------------- /src/components/NotificationsManager.vue: -------------------------------------------------------------------------------- 1 | 10 | 82 | -------------------------------------------------------------------------------- /src/views/PasswordRecovery.vue: -------------------------------------------------------------------------------- 1 | 55 | 56 | 80 | -------------------------------------------------------------------------------- /src/components/DynamicItemList.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 82 | -------------------------------------------------------------------------------- /src/api/question.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { apiUrl } from '@/env'; 3 | import { 4 | IGenericResponse, 5 | IQuestion, 6 | IQuestionArchive, 7 | IQuestionCreate, 8 | IQuestionPage, 9 | IQuestionUpdate, 10 | IQuestionUpvotes, 11 | } from '@/interfaces'; 12 | import { authHeaders } from '@/utils'; 13 | import { info } from '@/logging' 14 | 15 | export const apiQuestion = { 16 | async getQuestion(token: string, questionUUID: string) { 17 | if (token) { 18 | //info("get question with token: " + token); 19 | return axios.get(`${apiUrl}/questions/${questionUUID}`, authHeaders(token)); 20 | } else { 21 | //info("get question without token"); 22 | return axios.get(`${apiUrl}/questions/${questionUUID}`); 23 | } 24 | }, 25 | async getQuestionPage(token: string, questionUUID: string) { 26 | if (token) { 27 | return axios.get(`${apiUrl}/questions/${questionUUID}/page`, authHeaders(token)); 28 | } else { 29 | return axios.get(`${apiUrl}/questions/${questionUUID}/page`); 30 | } 31 | }, 32 | async upvoteQuestion(token: string, questionUUID: string) { 33 | return axios.post( 34 | `${apiUrl}/questions/${questionUUID}/upvotes/`, 35 | null, 36 | authHeaders(token) 37 | ); 38 | }, 39 | async cancelUpvoteQuestion(token: string, questionUUID: string) { 40 | return axios.delete( 41 | `${apiUrl}/questions/${questionUUID}/upvotes/`, 42 | authHeaders(token) 43 | ); 44 | }, 45 | async getQuestionArchives(token: string, questionUUID: string) { 46 | return axios.get( 47 | `${apiUrl}/questions/${questionUUID}/archives/`, 48 | authHeaders(token) 49 | ); 50 | }, 51 | async getUpvotes(token: string, questionUUID: string) { 52 | return axios.get( 53 | `${apiUrl}/questions/${questionUUID}/upvotes/`, 54 | authHeaders(token) 55 | ); 56 | }, 57 | async bumpViewsCounter(token: string, questionUUID: string) { 58 | return axios.post( 59 | `${apiUrl}/questions/${questionUUID}/views/`, 60 | null, 61 | authHeaders(token) 62 | ); 63 | }, 64 | async hideQuestion(token: string, questionUUID: string) { 65 | return axios.put( 66 | `${apiUrl}/questions/${questionUUID}/hide`, 67 | null, 68 | authHeaders(token) 69 | ); 70 | }, 71 | async postQuestion(token: string, data: IQuestionCreate) { 72 | return axios.post(`${apiUrl}/questions/`, data, authHeaders(token)); 73 | }, 74 | async updateQuestion(token: string, questionUUID: string, payload: IQuestionUpdate) { 75 | return axios.put(`${apiUrl}/questions/${questionUUID}`, payload, authHeaders(token)); 76 | }, 77 | }; 78 | -------------------------------------------------------------------------------- /src/components/NewInviteLinkBtn.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 76 | -------------------------------------------------------------------------------- /src/components/SimpleEditor.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 100 | -------------------------------------------------------------------------------- /src/store/main/mutations.ts: -------------------------------------------------------------------------------- 1 | import { IRichEditorState, ISite, ITopBanner, IUserProfile, ThemeType } from '@/interfaces'; 2 | import { AppNotification, MainState } from './state'; 3 | import { getStoreAccessors } from 'typesafe-vuex'; 4 | import { State } from '../state'; 5 | 6 | export const mutations = { 7 | setToken(state: MainState, payload: string) { 8 | state.token = payload; 9 | }, 10 | setLoggedIn(state: MainState, payload: boolean) { 11 | state.isLoggedIn = payload; 12 | }, 13 | setShowLoginPrompt(state: MainState, payload: boolean) { 14 | state.showLoginPrompt = payload; 15 | }, 16 | setLogInError(state: MainState, payload: boolean) { 17 | state.logInError = payload; 18 | }, 19 | setUserProfile(state: MainState, payload: IUserProfile) { 20 | state.userProfile = payload; 21 | }, 22 | setWorkingDraft(state: MainState, payload: IRichEditorState) { 23 | state.workingDraft = payload; 24 | }, 25 | setNarrowUI(state: MainState, payload: boolean) { 26 | state.narrowUI = payload; 27 | }, 28 | setModeratedSites(state: MainState, payload: ISite[]) { 29 | state.moderated_sites = payload; 30 | }, 31 | setTopBanner(state: MainState, payload: ITopBanner) { 32 | state.topBanner = payload; 33 | }, 34 | setDashboardMiniDrawer(state: MainState, payload: boolean) { 35 | state.dashboardMiniDrawer = payload; 36 | }, 37 | setDashboardShowDrawer(state: MainState, payload: boolean) { 38 | state.dashboardShowDrawer = payload; 39 | }, 40 | addNotification(state: MainState, payload: AppNotification) { 41 | state.notifications.push(payload); 42 | }, 43 | removeNotification(state: MainState, payload: AppNotification) { 44 | state.notifications = state.notifications.filter((notification) => notification !== payload); 45 | }, 46 | setTheme(state: MainState, theme: ThemeType) { 47 | state.theme = theme; 48 | }, 49 | }; 50 | 51 | const { commit } = getStoreAccessors(''); 52 | 53 | export const commitSetDashboardMiniDrawer = commit(mutations.setDashboardMiniDrawer); 54 | export const commitSetDashboardShowDrawer = commit(mutations.setDashboardShowDrawer); 55 | export const commitSetLoggedIn = commit(mutations.setLoggedIn); 56 | export const commitSetShowLoginPrompt = commit(mutations.setShowLoginPrompt); 57 | export const commitSetLogInError = commit(mutations.setLogInError); 58 | export const commitSetToken = commit(mutations.setToken); 59 | export const commitSetTopBanner = commit(mutations.setTopBanner); 60 | export const commitSetUserProfile = commit(mutations.setUserProfile); 61 | export const commitSetModeratedSites = commit(mutations.setModeratedSites); 62 | export const commitAddNotification = commit(mutations.addNotification); 63 | export const commitRemoveNotification = commit(mutations.removeNotification); 64 | export const commitSetWorkingDraft = commit(mutations.setWorkingDraft); 65 | export const commitSetNarrowUI = commit(mutations.setNarrowUI); 66 | export const commitSetTheme = commit(mutations.setTheme); 67 | -------------------------------------------------------------------------------- /src/views/main/dashboard/SiteCreation.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 80 | -------------------------------------------------------------------------------- /src/components/question/QuestionPreview.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 83 | -------------------------------------------------------------------------------- /src/components/TopicCard.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 88 | -------------------------------------------------------------------------------- /src/components/ExploreSitesGrid.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 92 | -------------------------------------------------------------------------------- /src/components/SearchBox.vue: -------------------------------------------------------------------------------- 1 | ] 2 | 44 | 45 | 111 | -------------------------------------------------------------------------------- /src/api/answer.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { apiUrl } from '@/env'; 3 | import { 4 | IAnswer, 5 | IAnswerArchive, 6 | IAnswerCreate, 7 | IAnswerDraft, 8 | IAnswerUpdate, 9 | IAnswerUpvotes, 10 | IGenericResponse, 11 | IAnswerSuggestEdit, 12 | IAnswerSuggestEditCreate, 13 | IAnswerSuggestEditUpdate, 14 | } from '@/interfaces'; 15 | import { authHeaders, authHeadersWithParams } from '@/utils'; 16 | 17 | export const apiAnswer = { 18 | async getAnswer(_token: string, answerUUID: string) { 19 | return axios.get(`${apiUrl}/answers/${answerUUID}`); 20 | }, 21 | async getAnswerUpvotes(_token: string, answerUUID: string) { 22 | return axios.get(`${apiUrl}/answers/${answerUUID}/upvotes/`); 23 | }, 24 | async upvoteAnswer(token: string, answerUUID: string) { 25 | return axios.post( 26 | `${apiUrl}/answers/${answerUUID}/upvotes/`, 27 | null, 28 | authHeaders(token) 29 | ); 30 | }, 31 | async cancelUpvoteAnswer(token: string, answerUUID: string) { 32 | return axios.delete( 33 | `${apiUrl}/answers/${answerUUID}/upvotes/`, 34 | authHeaders(token) 35 | ); 36 | }, 37 | async postAnswer(token: string, data: IAnswerCreate) { 38 | return axios.post(`${apiUrl}/answers/`, data, authHeaders(token)); 39 | }, 40 | async updateAnswer(token: string, answerUUID: string, payload: IAnswerUpdate) { 41 | return axios.put(`${apiUrl}/answers/${answerUUID}`, payload, authHeaders(token)); 42 | }, 43 | async deleteAnswer(token: string, answerUUID: string) { 44 | return axios.delete(`${apiUrl}/answers/${answerUUID}`, authHeaders(token)); 45 | }, 46 | async bumpViewsCounter(token: string, answerUUID: string) { 47 | return axios.post( 48 | `${apiUrl}/answers/${answerUUID}/views/`, 49 | null, 50 | authHeaders(token) 51 | ); 52 | }, 53 | async getAnswerDraft(token: string, answerUUID: string) { 54 | return axios.get(`${apiUrl}/answers/${answerUUID}/draft`, authHeaders(token)); 55 | }, 56 | async deleteAnswerDraft(token: string, answerUUID: string) { 57 | return axios.delete(`${apiUrl}/answers/${answerUUID}/draft`, authHeaders(token)); 58 | }, 59 | async getAnswerArchives(token: string, answerUUID: string, skip: number, limit: number) { 60 | const params = new URLSearchParams(); 61 | params.append('skip', skip.toString()); 62 | params.append('limit', limit.toString()); 63 | return axios.get( 64 | `${apiUrl}/answers/${answerUUID}/archives/`, 65 | authHeadersWithParams(token, params) 66 | ); 67 | }, 68 | async getSuggestions(token: string, uuid: string) { 69 | return axios.get( 70 | `${apiUrl}/answers/${uuid}/suggestions/`, 71 | authHeaders(token) 72 | ); 73 | }, 74 | async createSuggestion(token: string, payload: IAnswerSuggestEditCreate) { 75 | return axios.post( 76 | `${apiUrl}/answer-suggest-edits/`, 77 | payload, 78 | authHeaders(token) 79 | ); 80 | }, 81 | async updateSuggestion(token: string, uuid: string, payload: IAnswerSuggestEditUpdate) { 82 | return axios.put( 83 | `${apiUrl}/answer-suggest-edits/${uuid}`, 84 | payload, 85 | authHeaders(token) 86 | ); 87 | }, 88 | }; 89 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 107 | -------------------------------------------------------------------------------- /src/components/CommentBlock.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 95 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | 3 | ### Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ### Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ### Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ### Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ### Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at dev@cha.fan. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ### Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /src/components/SubmissionPreview.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 103 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chafan-pwa", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build", 8 | "test:unit": "vue-cli-service test:unit", 9 | "analyze": "source-map-explorer 'dist/js/*.js' --no-border-checks", 10 | "build:dev": "vue-cli-service build --mode development", 11 | "lint:css": "stylelint \"src/**/*.{css,scss,vue}\"", 12 | "lint:css:fix": "stylelint \"src/**/*.{css,scss,vue}\" --fix" 13 | }, 14 | "dependencies": { 15 | "@sentry/tracing": "^7.119.2", 16 | "@sentry/vue": "^7.119.2", 17 | "axios": "^1.12.2", 18 | "chafan-vue-editors": "^0.9.4", 19 | "core-js": "^3.40.0", 20 | "dayjs": "^1.11.13", 21 | "diff": "^5.2.0", 22 | "dompurify": "^3.2.6", 23 | "highlightjs": "^9.16.2", 24 | "html2canvas": "^1.4.1", 25 | "intersection-observer": "^0.12.2", 26 | "lodash": "^4.17.21", 27 | "piexifjs": "^1.0.6", 28 | "qrious": "^4.0.2", 29 | "register-service-worker": "^1.7.2", 30 | "sass": "^1.85.0", 31 | "typesafe-vuex": "^3.1.1", 32 | "uuid": "^11.0.3", 33 | "vee-validate": "^3.4.15", 34 | "vue": "^2.7.16", 35 | "vue-class-component": "^7.2.6", 36 | "vue-i18n": "^8.28.2", 37 | "vue-property-decorator": "^9.1.2", 38 | "vue-router": "^3.6.5", 39 | "vuetify": "^2.7.2", 40 | "vuex": "^3.6.2" 41 | }, 42 | "devDependencies": { 43 | "@babel/core": "^7.26.0", 44 | "@mdi/js": "^7.4.47", 45 | "@types/diff": "^5.2.3", 46 | "@types/jest": "^27.5.2", 47 | "@types/node": "^17.0.45", 48 | "@types/sanitize-html": "^2.13.0", 49 | "@vue/cli-plugin-babel": "~5.0.8", 50 | "@vue/cli-plugin-pwa": "~5.0.8", 51 | "@vue/cli-plugin-typescript": "~5.0.8", 52 | "@vue/cli-plugin-unit-jest": "~5.0.8", 53 | "@vue/cli-service": "~5.0.8", 54 | "@vue/cli-shared-utils": "~5.0.8", 55 | "@vue/test-utils": "^1.3.6", 56 | "autoprefixer": "^10.4.20", 57 | "babel-core": "^7.0.0-bridge.0", 58 | "body-parser": "^1.20.3", 59 | "command-line-args": "^6.0.0", 60 | "css-select": "^5.1.0", 61 | "css-what": "^6.1.0", 62 | "express": "^4.21.1", 63 | "express-ws": "^5.0.2", 64 | "jest": "^27.5.1", 65 | "lint-staged": "^15.2.10", 66 | "postcss": "^8.5.6", 67 | "postcss-html": "^1.8.0", 68 | "prettier": "^3.3.3", 69 | "sass-loader": "^10.5.2", 70 | "source-map-explorer": "^2.5.3", 71 | "stylelint": "^16.25.0", 72 | "stylelint-config-recommended-vue": "^1.6.1", 73 | "stylelint-config-standard-scss": "^14.0.0", 74 | "ts-jest": "^27.1.5", 75 | "typescript": "~4.9.5", 76 | "vue-cli-plugin-vuetify": "^2.5.8", 77 | "vue-template-compiler": "^2.7.16", 78 | "vuetify-loader": "^1.9.2", 79 | "webpack": "^5.96.1" 80 | }, 81 | "resolutions": { 82 | "node-forge": "^1.3.3", 83 | "glob": "^10.5.0" 84 | }, 85 | "postcss": { 86 | "plugins": { 87 | "autoprefixer": {} 88 | } 89 | }, 90 | "browserslist": [ 91 | "> 1%", 92 | "last 2 versions", 93 | "not dead" 94 | ], 95 | "jest": { 96 | "moduleFileExtensions": [ 97 | "js", 98 | "jsx", 99 | "json", 100 | "ts", 101 | "tsx" 102 | ], 103 | "transform": { 104 | ".+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$": "jest-transform-stub", 105 | "^.+\\.tsx?$": "ts-jest" 106 | }, 107 | "moduleNameMapper": { 108 | "^@/(.*)$": "/src/$1" 109 | }, 110 | "testURL": "http://localhost/" 111 | }, 112 | "packageManager": "yarn@4.9.1" 113 | } 114 | -------------------------------------------------------------------------------- /src/components/CreateQuestionForm.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 112 | -------------------------------------------------------------------------------- /src/api/people.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { apiUrl } from '@/env'; 3 | import { 4 | IAnswerPreview, 5 | IArticlePreview, 6 | IQuestionPreview, 7 | ISubmission, 8 | IUserEducationExperience, 9 | IUserPreview, 10 | IUserPublic, 11 | IUserSiteProfile, 12 | IUserWorkExperience, 13 | } from '@/interfaces'; 14 | import { authHeaders, authHeadersWithParams } from '@/utils'; 15 | 16 | export const apiPeople = { 17 | async getUserPublic(token: string, handle: string) { 18 | return axios.get(`${apiUrl}/people/${handle}`, authHeaders(token)); 19 | }, 20 | async getUserSiteProfiles(token: string, userUUID: string) { 21 | const params = new URLSearchParams(); 22 | return axios.get( 23 | `${apiUrl}/people/${userUUID}/site-profiles/`, 24 | authHeadersWithParams(token, params) 25 | ); 26 | }, 27 | async getUserFollowers(token: string, userUUID: string, skip: number, limit: number) { 28 | const params = new URLSearchParams(); 29 | params.append('skip', skip.toString()); 30 | params.append('limit', limit.toString()); 31 | return axios.get( 32 | `${apiUrl}/people/${userUUID}/followers/`, 33 | authHeadersWithParams(token, params) 34 | ); 35 | }, 36 | async getUserFollowed(token: string, userUUID: string, skip: number, limit: number) { 37 | const params = new URLSearchParams(); 38 | params.append('skip', skip.toString()); 39 | params.append('limit', limit.toString()); 40 | return axios.get( 41 | `${apiUrl}/people/${userUUID}/followed/`, 42 | authHeadersWithParams(token, params) 43 | ); 44 | }, 45 | async getRelatedUsers(token: string, userUUID: string) { 46 | return axios.get(`${apiUrl}/people/${userUUID}/related/`, authHeaders(token)); 47 | }, 48 | async getQuestionsByAuthor(_token: string, userUUID: string, skip: number, limit: number) { 49 | const params = new URLSearchParams(); 50 | params.append('skip', skip.toString()); 51 | params.append('limit', limit.toString()); 52 | return axios.get( 53 | `${apiUrl}/people/${userUUID}/questions/`, 54 | { params: params } 55 | ); 56 | }, 57 | async getSubmissionsByAuthor(_token: string, userUUID: string, skip: number, limit: number) { 58 | const params = new URLSearchParams(); 59 | params.append('skip', skip.toString()); 60 | params.append('limit', limit.toString()); 61 | return axios.get( 62 | `${apiUrl}/people/${userUUID}/submissions/`, 63 | { params: params } 64 | ); 65 | }, 66 | async getArticlesByAuthor(_token: string, userUUID: string, skip: number, limit: number) { 67 | const params = new URLSearchParams(); 68 | params.append('skip', skip.toString()); 69 | params.append('limit', limit.toString()); 70 | return axios.get( 71 | `${apiUrl}/people/${userUUID}/articles/`, 72 | { params: params } 73 | ); 74 | }, 75 | async getAnswersByAuthor(_token: string, userUUID: string, skip: number, limit: number) { 76 | const params = new URLSearchParams(); 77 | params.append('skip', skip.toString()); 78 | params.append('limit', limit.toString()); 79 | return axios.get( 80 | `${apiUrl}/people/${userUUID}/answers/`, 81 | { params: params } 82 | ); 83 | }, 84 | async getUserEducationExperiences(token: string, userUUID: string) { 85 | return axios.get( 86 | `${apiUrl}/people/${userUUID}/edu-exps/`, 87 | authHeaders(token) 88 | ); 89 | }, 90 | async getUserWorkExperiences(token: string, userUUID: string) { 91 | return axios.get( 92 | `${apiUrl}/people/${userUUID}/work-exps/`, 93 | authHeaders(token) 94 | ); 95 | }, 96 | }; 97 | -------------------------------------------------------------------------------- /src/api/submission.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { apiUrl } from '@/env'; 3 | import { authHeaders } from '@/utils'; 4 | import { 5 | IGenericResponse, 6 | ISubmission, 7 | ISubmissionArchive, 8 | ISubmissionCreate, 9 | ISubmissionSuggestion, 10 | ISubmissionSuggestionCreate, 11 | ISubmissionSuggestionUpdate, 12 | ISubmissionUpdate, 13 | ISubmissionUpvotes, 14 | } from '@/interfaces'; 15 | 16 | export const apiSubmission = { 17 | async postSubmission(token: string, data: ISubmissionCreate) { 18 | return axios.post(`${apiUrl}/submissions/`, data, authHeaders(token)); 19 | }, 20 | async getForUser(token: string) { 21 | return axios.get(`${apiUrl}/submissions/`, authHeaders(token)); 22 | }, 23 | async getSubmission(token: string, uuid: string) { 24 | return axios.get(`${apiUrl}/submissions/${uuid}`, authHeaders(token)); 25 | }, 26 | async bumpViewsCounter(token: string, uuid: string) { 27 | return axios.post( 28 | `${apiUrl}/submissions/${uuid}/views/`, 29 | null, 30 | authHeaders(token) 31 | ); 32 | }, 33 | async getUpvotes(token: string, uuid: string) { 34 | return axios.get( 35 | `${apiUrl}/submissions/${uuid}/upvotes/`, 36 | authHeaders(token) 37 | ); 38 | }, 39 | async updateSubmission(token: string, uuid: string, payload: ISubmissionUpdate) { 40 | return axios.put(`${apiUrl}/submissions/${uuid}`, payload, authHeaders(token)); 41 | }, 42 | async upvoteSubmission(token: string, uuid: string) { 43 | return axios.post( 44 | `${apiUrl}/submissions/${uuid}/upvotes/`, 45 | null, 46 | authHeaders(token) 47 | ); 48 | }, 49 | async cancelUpvoteSubmission(token: string, uuid: string) { 50 | return axios.delete( 51 | `${apiUrl}/submissions/${uuid}/upvotes/`, 52 | authHeaders(token) 53 | ); 54 | }, 55 | async getSubmissionArchives(token: string, uuid: string) { 56 | return axios.get( 57 | `${apiUrl}/submissions/${uuid}/archives/`, 58 | authHeaders(token) 59 | ); 60 | }, 61 | async getSubmissionUpvotes(token: string, uuid: string) { 62 | return axios.get( 63 | `${apiUrl}/submissions/${uuid}/upvotes/`, 64 | authHeaders(token) 65 | ); 66 | }, 67 | async getSuggestions(token: string, uuid: string) { 68 | return axios.get( 69 | `${apiUrl}/submissions/${uuid}/suggestions/`, 70 | authHeaders(token) 71 | ); 72 | }, 73 | async hideSubmission(token: string, uuid: string) { 74 | return axios.put(`${apiUrl}/submissions/${uuid}/hide`, null, authHeaders(token)); 75 | }, 76 | async createSuggestion(token: string, payload: ISubmissionSuggestionCreate) { 77 | return axios.post( 78 | `${apiUrl}/submission-suggestions/`, 79 | payload, 80 | authHeaders(token) 81 | ); 82 | }, 83 | async updateSubmissionSuggestion( 84 | token: string, 85 | uuid: string, 86 | payload: ISubmissionSuggestionUpdate 87 | ) { 88 | return axios.put( 89 | `${apiUrl}/submission-suggestions/${uuid}`, 90 | payload, 91 | authHeaders(token) 92 | ); 93 | }, 94 | async upvote(token: string, uuid: string) { 95 | return axios.post( 96 | `${apiUrl}/submissions/${uuid}/upvotes/`, 97 | null, 98 | authHeaders(token) 99 | ); 100 | }, 101 | async cancelUpvote(token: string, uuid: string) { 102 | return axios.delete( 103 | `${apiUrl}/submissions/${uuid}/upvotes/`, 104 | authHeaders(token) 105 | ); 106 | }, 107 | }; 108 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | 15 | 21 | 22 | 28 | 29 | 35 | 36 | 40 | 41 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 70 | 74 | 78 | <%= VUE_APP_NAME %> 79 | 80 | 81 | 87 |
88 | 89 | 90 | 91 | --------------------------------------------------------------------------------