-
Notifications
You must be signed in to change notification settings - Fork 10
feat(pastebin): add pastebin web app #268
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
6d07b3e
feat(pastebin): add pastebin web app
dschmidt fe16a96
fix(pastebin): fix scrollTo on public links, show anchor links everyw…
dschmidt 795fdb7
fix(pastebin): address review feedback from @JammingBen
dschmidt 11019b1
refactor(pastebin): use urlJoin for path concatenation
dschmidt e1c54d3
refactor(pastebin): use .pastebin file extension and extract FILE_EXT…
dschmidt b3535fa
style(pastebin): fix formatting
dschmidt 9ce19ef
fix(pastebin): use specific aria-label selector for breadcrumb nav in…
dschmidt ccd2128
fix(pastebin): use buttons instead of anchors for copy-to-clipboard a…
dschmidt 5a8cad0
fix(pastebin): use clipboard reads instead of data-href in e2e tests
dschmidt af8729f
style(pastebin): fix formatting
dschmidt File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| # web-app-pastebin | ||
|
|
||
| This is an application for creating and sharing text snippets and files via public links. Similar to traditional pastebin services, but integrated with OpenCloud's storage and sharing capabilities. | ||
|
|
||
| ## Features | ||
|
|
||
| - **Quick Text Sharing**: Create multiple text snippets at once via a simple interface | ||
| - **Automatic Organization**: Files are automatically organized in `.space/pastebin/` with timestamp-based folders | ||
| - **Public Link Generation**: Automatically generates shareable links | ||
| - **Multiple File Support**: Display multiple files in one pastebin | ||
| - **View Mode**: Dedicated view for displaying pastebin contents |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| /// <reference types="vite/client" /> | ||
|
|
||
| // FIXME: remove when extension-sdk provides its own types | ||
| declare module '@opencloud-eu/extension-sdk' { | ||
| const defineConfig: (config: any) => void | ||
| export { defineConfig } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| [main] | ||
| host = https://www.transifex.com | ||
|
|
||
| [o:opencloud-eu:p:opencloud-eu:r:web-extensions-pastebin] | ||
| file_filter = locale/<lang>/app.po | ||
| minimum_perc = 0 | ||
| resource_name = web-extensions-pastebin | ||
| source_file = template.pot | ||
| source_lang = en | ||
| type = PO |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| {} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| { | ||
| "name": "pastebin", | ||
| "version": "1.0.0", | ||
| "private": true, | ||
| "description": "OpenCloud Web Pastebin", | ||
| "license": "AGPL-3.0", | ||
| "type": "module", | ||
| "scripts": { | ||
| "build": "pnpm vite build", | ||
| "build:w": "pnpm vite build --watch --mode development", | ||
| "check:types": "vue-tsc --noEmit", | ||
| "test:unit": "NODE_OPTIONS=--unhandled-rejections=throw vitest" | ||
| }, | ||
| "dependencies": { | ||
| "highlight.js": "^11.11.0" | ||
| }, | ||
| "devDependencies": { | ||
| "@opencloud-eu/web-client": "^6.0.0", | ||
| "@opencloud-eu/web-pkg": "^6.0.0", | ||
| "@vueuse/core": "^14.0.0", | ||
| "vue": "^3.4.21", | ||
| "vue3-gettext": "^4.0.0-beta.1" | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,252 @@ | ||
| <template> | ||
| <div class="ext:flex ext:flex-col ext:h-full"> | ||
| <AppHeader> | ||
| <template #title> | ||
| <router-link | ||
| :to="{ name: 'pastebin-list' }" | ||
| class="ext:no-underline ext:opacity-60 hover:ext:opacity-100" | ||
| >{{ $gettext('Your Pastebins') }}</router-link | ||
| > | ||
| <span class="ext:mx-2 ext:opacity-40">/</span> | ||
| {{ $gettext('New') }} | ||
| </template> | ||
| </AppHeader> | ||
|
|
||
| <div class="ext:flex-1 ext:overflow-y-auto ext:p-5"> | ||
| <div class="ext:max-w-4xl ext:mx-auto"> | ||
| <oc-text-input | ||
| v-model="title" | ||
| :label="$gettext('Title')" | ||
| :fix-message-line="true" | ||
| class="ext:mb-2" | ||
| /> | ||
|
|
||
| <div class="ext:flex ext:flex-col ext:gap-4"> | ||
| <PastebinEditor | ||
| v-for="(file, index) in files" | ||
| :key="file.clientId" | ||
| :filename="file.filename" | ||
| :content="file.content" | ||
| :removable="files.length > 1" | ||
| @update:filename="file.filename = $event" | ||
| @update:content="file.content = $event" | ||
| @remove="files.splice(index, 1)" | ||
| /> | ||
| </div> | ||
|
|
||
| <oc-text-input | ||
| v-if="passwordRequired" | ||
| v-model="password" | ||
| type="password" | ||
| :label="$gettext('Link password')" | ||
| :password-policy="passwordPolicy" | ||
| :generate-password-method="generatePassword" | ||
| :error-message="passwordError" | ||
| :fix-message-line="true" | ||
| :required-mark="true" | ||
| class="ext:mt-4" | ||
| @update:model-value="passwordError = ''" | ||
| @password-challenge-completed="passwordValid = true" | ||
| @password-challenge-failed="passwordValid = false" | ||
| /> | ||
|
|
||
| <div class="ext:flex ext:items-center ext:justify-between ext:mt-4"> | ||
| <oc-button appearance="raw" size="small" @click="addFile"> | ||
| <oc-icon name="add" size="small" class="ext:mr-1" /> | ||
| {{ $gettext('Add file') }} | ||
| </oc-button> | ||
| <oc-button | ||
| appearance="filled" | ||
| size="medium" | ||
| :disabled="saving || !hasContent || (passwordRequired && !passwordValid)" | ||
| @click="loadingService.addTask(() => save())" | ||
| > | ||
| {{ saving ? $gettext('Creating…') : $gettext('Create Pastebin') }} | ||
| </oc-button> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </template> | ||
|
|
||
| <script setup lang="ts"> | ||
| import { computed, reactive, ref, unref } from 'vue' | ||
| import { | ||
| useClientService, | ||
| useLoadingService, | ||
| useMessages, | ||
| usePasswordPolicyService, | ||
| useResourcesStore, | ||
| useSpacesStore, | ||
| useSharesStore, | ||
| useLinkTypes, | ||
| useRouter, | ||
| contextRouteNameKey | ||
| } from '@opencloud-eu/web-pkg' | ||
| import { useGettext } from 'vue3-gettext' | ||
| import { useClipboard } from '@vueuse/core' | ||
| import AppHeader from './components/AppHeader.vue' | ||
| import PastebinEditor from './components/PastebinEditor.vue' | ||
| import { urlJoin } from '@opencloud-eu/web-client' | ||
| import { SharingLinkType } from '@opencloud-eu/web-client/graph/generated' | ||
| import { | ||
| slugify, | ||
| ensurePastebinFolders, | ||
| PASTEBIN_BASE_PATH, | ||
| MANIFEST_FILENAME, | ||
| REVISIONS_DIR, | ||
| DEFAULT_FILENAME, | ||
| FILE_EXTENSION | ||
| } from './utils' | ||
|
|
||
| const { $gettext } = useGettext() | ||
|
|
||
| const title = ref('') | ||
| const createFile = () => ({ clientId: crypto.randomUUID(), filename: '', content: '' }) | ||
| const files = reactive([createFile()]) | ||
| const saving = ref(false) | ||
|
|
||
| const { showMessage, showErrorMessage } = useMessages() | ||
| const clientService = useClientService() | ||
| const spacesStore = useSpacesStore() | ||
| const resourcesStore = useResourcesStore() | ||
| const { addLink } = useSharesStore() | ||
| const { defaultLinkType, isPasswordEnforcedForLinkType } = useLinkTypes() | ||
| const loadingService = useLoadingService() | ||
| const { copy } = useClipboard({ legacy: true }) | ||
| const passwordPolicyService = usePasswordPolicyService() | ||
| const router = useRouter() | ||
|
|
||
| const passwordRequired = computed(() => { | ||
| const linkType = unref(defaultLinkType) || SharingLinkType.View | ||
| return isPasswordEnforcedForLinkType(linkType) | ||
| }) | ||
| const password = ref('') | ||
| const passwordError = ref('') | ||
| const passwordValid = ref(false) | ||
| const passwordPolicy = computed(() => | ||
| passwordPolicyService.getPolicy({ enforcePassword: passwordRequired.value }) | ||
| ) | ||
| const generatePassword = () => passwordPolicyService.generatePassword() | ||
|
|
||
| const hasContent = computed(() => files.some((f) => f.content.trim())) | ||
|
|
||
| const addFile = () => { | ||
| files.push(createFile()) | ||
| } | ||
|
|
||
| const save = async () => { | ||
| if (!spacesStore.personalSpace) { | ||
| showErrorMessage({ | ||
| title: $gettext('Cannot create pastebin'), | ||
| errors: [new Error($gettext('No personal space available'))] | ||
| }) | ||
| return | ||
| } | ||
|
|
||
| const nonEmptyFiles = files.filter((f) => f.content.trim()) | ||
| if (nonEmptyFiles.length === 0 || (passwordRequired.value && !passwordValid.value)) { | ||
| return | ||
| } | ||
|
|
||
| saving.value = true | ||
|
|
||
| try { | ||
| const now = new Date() | ||
| const timestamp = now.toISOString().replace(/[:.]/g, '-') | ||
| const { webdav } = clientService | ||
|
|
||
| await ensurePastebinFolders(webdav, spacesStore.personalSpace) | ||
|
|
||
| const slug = title.value.trim() ? `-${slugify(title.value)}` : '' | ||
| const folderPath = urlJoin(PASTEBIN_BASE_PATH, `${timestamp}${slug}.${FILE_EXTENSION}`) | ||
|
|
||
| try { | ||
| await webdav.createFolder(spacesStore.personalSpace, { path: folderPath }) | ||
| } catch { | ||
| // may already exist | ||
| } | ||
|
|
||
| // Write manifest | ||
| const manifest = { title: title.value.trim() || `pastebin-${timestamp}` } | ||
| await webdav.putFileContents(spacesStore.personalSpace, { | ||
| path: urlJoin(folderPath, MANIFEST_FILENAME), | ||
| content: JSON.stringify(manifest, null, 2) | ||
| }) | ||
|
|
||
| // Create revisions/0/ and save files there | ||
| const revisionPath = urlJoin(folderPath, REVISIONS_DIR) | ||
| try { | ||
| await webdav.createFolder(spacesStore.personalSpace, { path: revisionPath }) | ||
| } catch { | ||
| // may already exist | ||
| } | ||
| const currentRevisionPath = urlJoin(revisionPath, '0') | ||
| try { | ||
| await webdav.createFolder(spacesStore.personalSpace, { path: currentRevisionPath }) | ||
| } catch { | ||
| // may already exist | ||
| } | ||
|
|
||
| for (const file of nonEmptyFiles) { | ||
| const filename = file.filename.trim() || DEFAULT_FILENAME | ||
| await webdav.putFileContents(spacesStore.personalSpace, { | ||
| path: urlJoin(currentRevisionPath, filename), | ||
| content: file.content | ||
| }) | ||
| } | ||
|
|
||
| // Create public link | ||
| const folderResource = await webdav.getFileInfo(spacesStore.personalSpace, { path: folderPath }) | ||
| resourcesStore.initResourceList({ currentFolder: folderResource, resources: [] }) | ||
|
|
||
| try { | ||
| const linkType = unref(defaultLinkType) || SharingLinkType.View | ||
|
|
||
| const linkShare = await addLink({ | ||
| clientService, | ||
| space: spacesStore.personalSpace, | ||
| resource: folderResource, | ||
| options: { | ||
| '@libre.graph.quickLink': false, | ||
| displayName: title.value.trim() || `pastebin-${timestamp}`, | ||
| type: linkType, | ||
| ...(passwordRequired.value && password.value && { password: password.value }) | ||
| } | ||
| }) | ||
|
|
||
| const clipboardText = linkShare.hasPassword | ||
| ? `${linkShare.webUrl}\nPassword: ${password.value}` | ||
| : linkShare.webUrl | ||
| await copy(clipboardText) | ||
|
|
||
| showMessage({ | ||
| title: $gettext('Pastebin created and link copied to clipboard!'), | ||
| desc: linkShare.webUrl | ||
| }) | ||
| } catch (linkError) { | ||
| console.error('Failed to create public link:', linkError) | ||
| showMessage({ title: $gettext('Pastebin created successfully') }) | ||
| showErrorMessage({ | ||
| title: $gettext('Failed to create public link'), | ||
| errors: [linkError instanceof Error ? linkError : new Error(String(linkError))] | ||
| }) | ||
| } | ||
|
|
||
| const driveAliasAndItem = spacesStore.personalSpace.getDriveAliasAndItem(folderResource) | ||
| await router.push({ | ||
| name: 'pastebin-view', | ||
| params: { driveAliasAndItem }, | ||
| query: { fileId: folderResource.fileId, [contextRouteNameKey]: 'pastebin-list' } | ||
| }) | ||
| } catch (error) { | ||
| console.error('Failed to create pastebin:', error) | ||
| showErrorMessage({ | ||
| title: $gettext('Failed to create pastebin'), | ||
| errors: [error instanceof Error ? error : new Error(String(error))] | ||
| }) | ||
| } finally { | ||
| saving.value = false | ||
| } | ||
| } | ||
| </script> | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.