Compare commits
	
		
			3 Commits
		
	
	
		
			7d3236550c
			...
			db5d631049
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| db5d631049 | |||
| 2d7dd26882 | |||
| b0834f48d4 | 
| @@ -18,6 +18,9 @@ | ||||
|     "@fingerprintjs/fingerprintjs": "^4.6.2", | ||||
|     "@fontsource-variable/nunito": "^5.2.6", | ||||
|     "@hcaptcha/vue3-hcaptcha": "^1.3.0", | ||||
|     "@milkdown/crepe": "^7.15.2", | ||||
|     "@milkdown/kit": "^7.15.2", | ||||
|     "@milkdown/vue": "^7.15.2", | ||||
|     "@tailwindcss/typography": "^0.5.16", | ||||
|     "@tailwindcss/vite": "^4.1.11", | ||||
|     "@vueuse/core": "^13.5.0", | ||||
|   | ||||
| @@ -4,6 +4,8 @@ | ||||
|       <img src="/image-broken.jpg" class="w-32 h-32 rounded-md" /> | ||||
|     </template> | ||||
|   </n-image> | ||||
|   <audio v-else-if="itemType == 'audio'" :src="remoteSource" controls /> | ||||
|   <video v-else-if="itemType == 'video'" :src="remoteSource" controls /> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| @@ -14,5 +16,5 @@ const props = defineProps<{ item: any }>() | ||||
|  | ||||
| const itemType = computed(() => props.item.mime_type.split('/')[0] ?? 'unknown') | ||||
|  | ||||
| const remoteSource = computed(() => `/cgi/drive/files/${props.item.id}`) | ||||
| const remoteSource = computed(() => `/cgi/drive/files/${props.item.id}?original=true`) | ||||
| </script> | ||||
|   | ||||
							
								
								
									
										178
									
								
								DysonNetwork.Sphere/Client/src/components/PostEditor.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										178
									
								
								DysonNetwork.Sphere/Client/src/components/PostEditor.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,178 @@ | ||||
| <template> | ||||
|   <n-upload | ||||
|     abstract | ||||
|     with-credentials | ||||
|     @remove="handleRemove" | ||||
|     :create-thumbnail-url="createThumbnailUrl" | ||||
|     :custom-request="customRequest" | ||||
|     v-model:file-list="fileList" | ||||
|   > | ||||
|     <div class="flex flex-col gap-2"> | ||||
|       <pub-select v-model:value="publisher" /> | ||||
|       <n-input | ||||
|         type="textarea" | ||||
|         placeholder="What's happended?!" | ||||
|         v-model:value="content" | ||||
|         @keydown.meta.enter.exact="submit" | ||||
|         @keydown.ctrl.enter.exact="submit" | ||||
|       /> | ||||
|       <n-upload-file-list v-if="fileList" /> | ||||
|       <div class="flex justify-between"> | ||||
|         <div class="flex gap-2"> | ||||
|           <n-upload-trigger #="{ handleClick }" abstract> | ||||
|             <n-button @click="handleClick"> | ||||
|               <n-icon><upload-round /></n-icon> | ||||
|             </n-button> | ||||
|           </n-upload-trigger> | ||||
|         </div> | ||||
|         <n-button type="primary" icon-placement="right" :loading="submitting" @click="submit"> | ||||
|           Post | ||||
|           <template #icon> | ||||
|             <n-icon><send-round /></n-icon> | ||||
|           </template> | ||||
|         </n-button> | ||||
|       </div> | ||||
|     </div> | ||||
|   </n-upload> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import { | ||||
|   NInput, | ||||
|   NButton, | ||||
|   NIcon, | ||||
|   NUpload, | ||||
|   NUploadFileList, | ||||
|   NUploadTrigger, | ||||
|   useMessage, | ||||
|   type UploadSettledFileInfo, | ||||
|   type UploadCustomRequestOptions, | ||||
|   create, | ||||
|   type UploadFileInfo, | ||||
| } from 'naive-ui' | ||||
| import { SendRound, UploadRound } from '@vicons/material' | ||||
| import { ref } from 'vue' | ||||
| import * as tus from 'tus-js-client' | ||||
|  | ||||
| import PubSelect from './PubSelect.vue' | ||||
|  | ||||
| const emits = defineEmits(['posted']) | ||||
|  | ||||
| const publisher = ref<string | undefined>() | ||||
| const content = ref('') | ||||
|  | ||||
| const fileList = ref<UploadFileInfo[]>([]) | ||||
|  | ||||
| const submitting = ref(false) | ||||
|  | ||||
| async function submit() { | ||||
|   submitting.value = true | ||||
|   await fetch(`/api/posts?pub=${publisher.value}`, { | ||||
|     method: 'POST', | ||||
|     headers: { | ||||
|       'content-type': 'application/json', | ||||
|     }, | ||||
|     body: JSON.stringify({ | ||||
|       content: content.value, | ||||
|       attachments: fileList.value | ||||
|         .filter((e) => e.url != null) | ||||
|         .map((e) => e.url!.split('/').reverse()[0]), | ||||
|     }), | ||||
|   }) | ||||
|  | ||||
|   submitting.value = false | ||||
|   content.value = '' | ||||
|   fileList.value = [] | ||||
|   emits('posted') | ||||
| } | ||||
|  | ||||
| const messageDisplay = useMessage() | ||||
|  | ||||
| function customRequest({ | ||||
|   file, | ||||
|   headers, | ||||
|   withCredentials, | ||||
|   onFinish, | ||||
|   onError, | ||||
|   onProgress, | ||||
| }: UploadCustomRequestOptions) { | ||||
|   const requestHeaders: Record<string, string> = {} | ||||
|   const upload = new tus.Upload(file.file, { | ||||
|     endpoint: '/cgi/drive/tus', | ||||
|     retryDelays: [0, 3000, 5000, 10000, 20000], | ||||
|     removeFingerprintOnSuccess: false, | ||||
|     uploadDataDuringCreation: false, | ||||
|     metadata: { | ||||
|       filename: file.name, | ||||
|       'content-type': file.type ?? 'application/octet-stream', | ||||
|     }, | ||||
|     headers: { | ||||
|       'X-DirectUpload': 'true', | ||||
|       ...requestHeaders, | ||||
|       ...headers, | ||||
|     }, | ||||
|     onShouldRetry: () => false, | ||||
|     onError: function (error) { | ||||
|       if (error instanceof tus.DetailedError) { | ||||
|         const failedBody = error.originalResponse?.getBody() | ||||
|         if (failedBody != null) | ||||
|           messageDisplay.error(`Upload failed: ${failedBody}`, { | ||||
|             duration: 10000, | ||||
|             closable: true, | ||||
|           }) | ||||
|       } | ||||
|       console.error('[DRIVE] Upload failed:', error) | ||||
|       onError() | ||||
|     }, | ||||
|     onProgress: function (bytesUploaded, bytesTotal) { | ||||
|       onProgress({ percent: (bytesUploaded / bytesTotal) * 100 }) | ||||
|     }, | ||||
|     onSuccess: function (payload) { | ||||
|       const rawInfo = payload.lastResponse.getHeader('x-fileinfo') | ||||
|       const jsonInfo = JSON.parse(rawInfo as string) | ||||
|       console.log('[DRIVE] Upload successful: ', jsonInfo) | ||||
|       file.url = `/cgi/drive/files/${jsonInfo.id}` | ||||
|       file.type = jsonInfo.mime_type | ||||
|       onFinish() | ||||
|     }, | ||||
|     onBeforeRequest: function (req) { | ||||
|       const xhr = req.getUnderlyingObject() | ||||
|       xhr.withCredentials = withCredentials | ||||
|     }, | ||||
|   }) | ||||
|   upload.findPreviousUploads().then(function (previousUploads) { | ||||
|     if (previousUploads.length) { | ||||
|       upload.resumeFromPreviousUpload(previousUploads[0]) | ||||
|     } | ||||
|     upload.start() | ||||
|   }) | ||||
| } | ||||
|  | ||||
| function createThumbnailUrl( | ||||
|   _file: File | null, | ||||
|   fileInfo: UploadSettledFileInfo, | ||||
| ): string | undefined { | ||||
|   if (!fileInfo) return undefined | ||||
|   return fileInfo.url ?? undefined | ||||
| } | ||||
|  | ||||
| function handleRemove(data: { file: UploadFileInfo; fileList: UploadFileInfo[] }) { | ||||
|   if (data.file.url == null) return true | ||||
|   const messageReactive = messageDisplay.loading('Deleting files...', { | ||||
|     duration: 0, | ||||
|   }) | ||||
|   return new Promise((resolve) => { | ||||
|     fetch(`/cgi/drive/files/${data.file.url!.split('/').reverse()[0]}`, { method: 'DELETE' }) | ||||
|       .then(() => { | ||||
|         messageReactive.destroy() | ||||
|         messageDisplay.success('File has been deleted') | ||||
|         resolve(true) | ||||
|       }) | ||||
|       .catch((err) => { | ||||
|         messageReactive.destroy() | ||||
|         messageDisplay.error('Unable to delete this file: ' + err) | ||||
|         resolve(false) | ||||
|       }) | ||||
|   }) | ||||
| } | ||||
| </script> | ||||
							
								
								
									
										19
									
								
								DysonNetwork.Sphere/Client/src/components/PostEditorPro.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								DysonNetwork.Sphere/Client/src/components/PostEditorPro.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| <template> | ||||
|   <div> | ||||
|     <milkdown /> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import { Milkdown, useEditor } from "@milkdown/vue"; | ||||
| import { Crepe } from "@milkdown/crepe"; | ||||
| import "@milkdown/crepe/theme/common/style.css"; | ||||
| import "@milkdown/crepe/theme/frame.css"; | ||||
|  | ||||
| useEditor((root) => { | ||||
|   const crepe = new Crepe({ | ||||
|     root, | ||||
|   }); | ||||
|   return crepe; | ||||
| }) | ||||
| </script> | ||||
							
								
								
									
										97
									
								
								DysonNetwork.Sphere/Client/src/components/PubSelect.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										97
									
								
								DysonNetwork.Sphere/Client/src/components/PubSelect.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,97 @@ | ||||
| <template> | ||||
|   <n-select | ||||
|     :options="pubStore.publishers" | ||||
|     label-field="nick" | ||||
|     value-field="name" | ||||
|     :value="props.value" | ||||
|     :render-label="renderLabel" | ||||
|     :render-tag="renderSingleSelectTag" | ||||
|     @update:value="(v) => emits('update:value', v)" | ||||
|   /> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import { usePubStore } from '@/stores/pub' | ||||
| import { NAvatar, NSelect, NText, type SelectRenderLabel, type SelectRenderTag } from 'naive-ui' | ||||
| import { h, watch } from 'vue' | ||||
|  | ||||
| const pubStore = usePubStore() | ||||
|  | ||||
| const props = defineProps<{ value: string | undefined }>() | ||||
| const emits = defineEmits(['update:value']) | ||||
|  | ||||
| watch( | ||||
|   pubStore, | ||||
|   (value) => { | ||||
|     if (!props.value && value.publishers) { | ||||
|       emits('update:value', pubStore.publishers[0].name) | ||||
|     } | ||||
|   }, | ||||
|   { deep: true, immediate: true }, | ||||
| ) | ||||
|  | ||||
| const renderSingleSelectTag: SelectRenderTag = ({ option }: { option: any }) => { | ||||
|   return h( | ||||
|     'div', | ||||
|     { | ||||
|       style: { | ||||
|         display: 'flex', | ||||
|         alignItems: 'center', | ||||
|       }, | ||||
|     }, | ||||
|     [ | ||||
|       h(NAvatar, { | ||||
|         src: option.picture | ||||
|           ? `/cgi/drive/files/${option.picture.id}` | ||||
|           : undefined, | ||||
|         round: true, | ||||
|         size: 24, | ||||
|         style: { | ||||
|           marginRight: '8px', | ||||
|         }, | ||||
|       }), | ||||
|       option.nick as string, | ||||
|     ], | ||||
|   ) | ||||
| } | ||||
|  | ||||
| const renderLabel: SelectRenderLabel = (option: any) => { | ||||
|   return h( | ||||
|     'div', | ||||
|     { | ||||
|       style: { | ||||
|         display: 'flex', | ||||
|         alignItems: 'center', | ||||
|       }, | ||||
|     }, | ||||
|     [ | ||||
|       h(NAvatar, { | ||||
|         src: option.picture | ||||
|           ? `/cgi/drive/files/${option.picture.id}` | ||||
|           : undefined, | ||||
|         round: true, | ||||
|         size: 'small', | ||||
|       }), | ||||
|       h( | ||||
|         'div', | ||||
|         { | ||||
|           style: { | ||||
|             marginLeft: '8px', | ||||
|             padding: '4px 0', | ||||
|           }, | ||||
|         }, | ||||
|         [ | ||||
|           h('div', null, [option.nick as string]), | ||||
|           h( | ||||
|             NText, | ||||
|             { depth: 3, tag: 'div' }, | ||||
|             { | ||||
|               default: () => `@${option.name}`, | ||||
|             }, | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|     ], | ||||
|   ) | ||||
| } | ||||
| </script> | ||||
| @@ -15,6 +15,8 @@ import { usePreferredDark } from '@vueuse/core' | ||||
| import { useUserStore } from './stores/user' | ||||
| import { onMounted } from 'vue' | ||||
| import { useServicesStore } from './stores/services' | ||||
| import { MilkdownProvider } from '@milkdown/vue' | ||||
| import { usePubStore } from './stores/pub' | ||||
|  | ||||
| const themeOverrides = { | ||||
|   common: { | ||||
| @@ -30,26 +32,30 @@ const isDark = usePreferredDark() | ||||
|  | ||||
| const userStore = useUserStore() | ||||
| const servicesStore = useServicesStore() | ||||
| const pubStore = usePubStore() | ||||
|  | ||||
| onMounted(() => { | ||||
|   userStore.initialize() | ||||
|  | ||||
|   userStore.fetchUser() | ||||
|   servicesStore.fetchServices() | ||||
|   pubStore.fetchPublishers() | ||||
| }) | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <n-config-provider :theme-overrides="themeOverrides" :theme="isDark ? darkTheme : lightTheme"> | ||||
|     <n-global-style /> | ||||
|     <n-loading-bar-provider> | ||||
|       <n-dialog-provider> | ||||
|         <n-message-provider placement="bottom"> | ||||
|           <layout-default> | ||||
|             <router-view /> | ||||
|           </layout-default> | ||||
|         </n-message-provider> | ||||
|       </n-dialog-provider> | ||||
|     </n-loading-bar-provider> | ||||
|   </n-config-provider> | ||||
|   <milkdown-provider> | ||||
|     <n-config-provider :theme-overrides="themeOverrides" :theme="isDark ? darkTheme : lightTheme"> | ||||
|       <n-global-style /> | ||||
|       <n-loading-bar-provider> | ||||
|         <n-dialog-provider> | ||||
|           <n-message-provider placement="bottom"> | ||||
|             <layout-default> | ||||
|               <router-view /> | ||||
|             </layout-default> | ||||
|           </n-message-provider> | ||||
|         </n-dialog-provider> | ||||
|       </n-loading-bar-provider> | ||||
|     </n-config-provider> | ||||
|   </milkdown-provider> | ||||
| </template> | ||||
|   | ||||
							
								
								
									
										13
									
								
								DysonNetwork.Sphere/Client/src/stores/pub.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								DysonNetwork.Sphere/Client/src/stores/pub.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| import { defineStore } from 'pinia' | ||||
| import { ref } from 'vue' | ||||
|  | ||||
| export const usePubStore = defineStore('pub', () => { | ||||
|   const publishers = ref<any[]>([]) | ||||
|  | ||||
|   async function fetchPublishers() { | ||||
|     const resp = await fetch('/api/publishers') | ||||
|     publishers.value = await resp.json() | ||||
|   } | ||||
|  | ||||
|   return { publishers, fetchPublishers } | ||||
| }) | ||||
| @@ -9,7 +9,7 @@ | ||||
|         </n-infinite-scroll> | ||||
|       </n-gi> | ||||
|       <n-gi span="2" class="max-lg:order-first"> | ||||
|         <n-card class="w-full mt-4" title="About"> | ||||
|         <n-card class="w-full mt-4" title="About" v-if="!userStore.user"> | ||||
|           <p>Welcome to the <b>Solar Network</b></p> | ||||
|           <p>The open social network. Friendly to everyone.</p> | ||||
|  | ||||
| @@ -22,16 +22,27 @@ | ||||
|             </span> | ||||
|           </p> | ||||
|         </n-card> | ||||
|         <n-card class="mt-4 w-full"> | ||||
|           <post-editor @posted="refreshActivities" /> | ||||
|         </n-card> | ||||
|         <n-alert closable class="mt-4" w-full type="info" title="Looking for Solian?"> | ||||
|           The flutter based web app Solian has been moved to | ||||
|           <n-a href="https://web.solian.app" target="_blank">web.solian.app</n-a> | ||||
|           <n-hr /> | ||||
|           网页版 Solian 已经被移动到 | ||||
|           <n-a href="https://web.solian.app" target="_blank">web.solian.app</n-a> | ||||
|         </n-alert> | ||||
|       </n-gi> | ||||
|     </n-grid> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import { NCard, NInfiniteScroll, NGrid, NGi } from 'naive-ui' | ||||
| import { NCard, NInfiniteScroll, NGrid, NGi, NAlert, NA, NHr } from 'naive-ui' | ||||
| import { computed, onMounted, ref } from 'vue' | ||||
| import { useUserStore } from '@/stores/user' | ||||
|  | ||||
| import PostEditor from '@/components/PostEditor.vue' | ||||
| import PostItem from '@/components/PostItem.vue' | ||||
|  | ||||
| const userStore = useUserStore() | ||||
| @@ -46,22 +57,27 @@ onMounted(() => fetchVersion()) | ||||
| const loading = ref(false) | ||||
|  | ||||
| const activites = ref<any[]>([]) | ||||
| const activitesLast = computed( | ||||
|   () => | ||||
|     activites.value.sort( | ||||
|       (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime(), | ||||
|     )[0], | ||||
| ) | ||||
| const activitesLast = computed(() => activites.value[Math.max(activites.value.length - 1, 0)]) | ||||
| const activitesHasMore = ref(true) | ||||
|  | ||||
| async function fetchActivites() { | ||||
|   if (loading.value) return | ||||
|   if (!activitesHasMore.value) return | ||||
|   loading.value = true | ||||
|   const resp = await fetch( | ||||
|     activitesLast.value == null | ||||
|       ? '/api/activities' | ||||
|       : `/api/activities?cursor=${new Date(activitesLast.value.created_at).toISOString()}`, | ||||
|   ) | ||||
|   activites.value.push(...(await resp.json())) | ||||
|   const data = await resp.json() | ||||
|   activites.value = [...activites.value, ...data] | ||||
|   activitesHasMore.value = data[0]?.type != 'empty' | ||||
|   loading.value = false | ||||
| } | ||||
| onMounted(() => fetchActivites()) | ||||
|  | ||||
| async function refreshActivities() { | ||||
|   activites.value = [] | ||||
|   fetchActivites() | ||||
| } | ||||
| </script> | ||||
|   | ||||
| @@ -19,6 +19,10 @@ export default defineConfig({ | ||||
|   }, | ||||
|   server: { | ||||
|     proxy: { | ||||
|       '/api/tus': { | ||||
|         target: 'http://localhost:5090', | ||||
|         changeOrigin: true, | ||||
|       }, | ||||
|       '/api': { | ||||
|         target: 'http://localhost:5071', | ||||
|         changeOrigin: true, | ||||
|   | ||||
		Reference in New Issue
	
	Block a user