feat: Public sharing of collections (#9529)

* shares.info, collections.info, documents.info

* shares.list, shares.create, shares.update

* shares.sitemap

* parity with existing document shared screen

* collection share popover

* parent share and table

* collection scene

* collection link in sidebar

* sidebar and breadcrumb collection link click

* collection link click in editor

* meta

* more meta + 404 page

* map internal link, remove showLastUpdated option

* fix shares.list pagination

* show last updated

* shareLoader tests

* lint

* sidebar context for collection link

* badge in shares table

* fix existing tests

* tsc

* update failing test snapshot

* env

* signed url for collection attachments

* include collection content in SSR for screen readers

* search

* drafts can be shared

* review

* tsc, remove old shared-doc scene

* tweaks

* DRY

* refactor loader

* Remove share/collection urls

* fix: Collection overview should not be editable when viewing shared link and logged in

* Tweak public breadcrumb

* fix: Deleted documents should never be exposed through share

* empty sharedTree array where includeChildDocuments is false

* revert includeChildDocs guard for logical correctness + SSR bug fix

* fix: check document is part of share

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
This commit is contained in:
Hemachandar
2025-08-03 22:37:39 +05:30
committed by GitHub
parent f7826c7d91
commit d3eb3db7ba
55 changed files with 2237 additions and 708 deletions
+7
View File
@@ -320,6 +320,13 @@ export default class Collection extends ParanoidModel {
this.index = index;
}
@action
share = async () =>
this.store.rootStore.shares.create({
type: "collection",
collectionId: this.id,
});
getChildrenForDocument(documentId: string) {
let result: NavigationNode[] = [];
+2 -1
View File
@@ -330,7 +330,7 @@ export default class Document extends ArchivableModel implements Searchable {
get isPubliclyShared(): boolean {
const { shares, auth } = this.store.rootStore;
const share = shares.getByDocumentId(this.id);
const sharedParent = shares.getByDocumentParents(this.id);
const sharedParent = shares.getByDocumentParents(this);
return !!(
auth.team?.sharing !== false &&
@@ -461,6 +461,7 @@ export default class Document extends ArchivableModel implements Searchable {
@action
share = async () =>
this.store.rootStore.shares.create({
type: "document",
documentId: this.id,
});
+36 -1
View File
@@ -1,4 +1,7 @@
import { computed, observable } from "mobx";
import { NavigationNode, PublicTeam } from "@shared/types";
import SharesStore from "~/stores/SharesStore";
import env from "~/env";
import Collection from "./Collection";
import Document from "./Document";
import User from "./User";
@@ -10,6 +13,8 @@ import { Searchable } from "./interfaces/Searchable";
class Share extends Model implements Searchable {
static modelName = "Share";
store: SharesStore;
@Field
@observable
published: boolean;
@@ -44,6 +49,12 @@ class Share extends Model implements Searchable {
@observable
domain: string;
@observable
sourceTitle: string;
@observable
sourcePath: string;
@observable
documentTitle: string;
@@ -71,9 +82,33 @@ class Share extends Model implements Searchable {
@Relation(() => User, { onDelete: "null" })
createdBy: User;
static sitemapUrl(shareId: string) {
return `${env.URL}/api/shares.sitemap?shareId=${shareId}`;
}
@computed
get title(): string {
return this.sourceTitle ?? this.documentTitle;
}
@computed
get sourcePathWithFallback(): string {
return this.sourcePath ?? this.documentUrl;
}
@computed
get searchContent(): string[] {
return [this.document?.title ?? this.documentTitle];
return [this.title];
}
@computed
get team(): PublicTeam | undefined {
return this.store.sharedCache.get(this.id)?.team;
}
@computed
get tree(): NavigationNode | undefined {
return this.store.sharedCache.get(this.id)?.sharedTree ?? undefined;
}
}