Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions config/live_preview.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,19 @@

'hot_reload_contents' => true,

/*
|--------------------------------------------------------------------------
| Shared Preview Links
|--------------------------------------------------------------------------
|
| How long shareable draft preview links should remain valid, in minutes.
| Set shared_link_banner to false to hide the "Draft preview" bar on
| shared frontend pages. Cascade still gets `shared_preview`.
|
*/

'shared_link_expiry' => 1440,

'shared_link_banner' => true,

];
3 changes: 3 additions & 0 deletions lang/en/messages.php
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,9 @@
'session_expiry_logging_out_in_seconds' => 'You have been inactive for a while and will be logged out in :seconds seconds.',
'session_expiry_new_window' => 'This will open in a new window. Return here after authenticating.',
'set_new_password_instructions' => 'Confirm your email address and create a new password.',
'shared_preview_banner' => 'Draft preview · Expires :date',
'shared_preview_banner_revision' => 'Revision preview · Expires :date',
'shared_preview_link_copied' => 'Preview link copied. Expires in :hours hours.',
'show_slugs_instructions' => 'Show slugs in the tree view.',
'site_configure_attributes_instructions' => 'Add arbitrary attributes to your site\'s config which can be accessed in your templates. [Learn more](https://statamic.dev/multi-site#additional-attributes).',
'site_configure_handle_instructions' => 'A unique reference to this site. This cannot be easily changed later.',
Expand Down
61 changes: 55 additions & 6 deletions resources/js/components/entries/PublishForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -101,23 +101,35 @@
<PublishTabs>
<template #actions>
<div class="space-y-6">
<!-- Live Preview / Visit URL Buttons -->
<div class="flex flex-wrap gap-3 lg:gap-4" v-if="showLivePreviewButton || showVisitUrlButton">
<!-- Live Preview / Visit URL / Share Buttons -->
<div
class="flex"
:class="showCopyPreviewLinkButton ? 'gap-2' : 'flex-wrap gap-3 lg:gap-4'"
v-if="showLivePreviewButton || showVisitUrlButton || showCopyPreviewLinkButton"
>
<Button
:text="__('Live Preview')"
class="flex-1"
:text="showCopyPreviewLinkButton ? __('Preview') : __('Live Preview')"
:class="showCopyPreviewLinkButton ? 'min-w-0 flex-1 px-2!' : 'flex-1'"
icon="live-preview"
@click="openLivePreview"
v-if="showLivePreviewButton"
/>
<Button
:href="permalink"
:text="__('Visit URL')"
class="flex-1"
:text="showCopyPreviewLinkButton ? __('Visit') : __('Visit URL')"
:class="showCopyPreviewLinkButton ? 'min-w-0 flex-1 px-2!' : 'flex-1'"
icon="external-link"
target="_blank"
v-if="showVisitUrlButton"
/>
<Button
:text="__('Share')"
class="min-w-0 flex-1 px-2!"
icon="share-link"
:loading="copyingPreviewLink"
@click="copyPreviewLink"
v-if="showCopyPreviewLinkButton"
/>
</div>

<!-- Published Switch -->
Expand Down Expand Up @@ -205,6 +217,7 @@
<revision-history
:index-url="actions.revisions"
:restore-url="actions.restore"
:shared-preview-url="actions.sharedPreview"
:reference="initialReference"
:can-restore-revisions="!readOnly"
@closed="$refs.revisionHistoryStack.close()"
Expand Down Expand Up @@ -390,6 +403,7 @@ export default {
confirmingPublish: false,
readOnly: this.initialReadOnly,
permalink: this.initialPermalink,
copyingPreviewLink: false,

saveKeyBinding: null,
quickSaveKeyBinding: null,
Expand Down Expand Up @@ -464,6 +478,20 @@ export default {
return !!this.permalink;
},

showCopyPreviewLinkButton() {
if (this.isCreating || !this.isBase || !this.actions.sharedPreview) {
return false;
}

if (this.isWorkingCopy) {
return true;
}

const status = this.activeLocalization?.status;

return this.isDraft || status === 'scheduled' || status === 'expired';
},

showLocalizationSelector() {
return this.localizations.length > 1;
},
Expand Down Expand Up @@ -742,6 +770,27 @@ export default {
.then(() => (this.tabsVisible = true));
},

async copyPreviewLink() {
this.copyingPreviewLink = true;

try {
const { data } = await this.$axios.post(this.actions.sharedPreview);

try {
await navigator.clipboard.writeText(data.url);
this.$toast.success(
__('messages.shared_preview_link_copied', { hours: data.expires_in_hours }),
);
} catch {
Statamic.$callbacks.call('copyToClipboard', data.url);
}
} catch {
this.$toast.error(__('Unable to copy preview link'));
} finally {
this.copyingPreviewLink = false;
}
},

closeLivePreview() {
this.isPreviewing = false;
this.tabsVisible = true;
Expand Down
2 changes: 2 additions & 0 deletions resources/js/components/revision-history/History.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
:key="revision.date"
:revision="revision"
:restore-url="restoreUrl"
:shared-preview-url="sharedPreviewUrl"
:reference="reference"
:can-restore-revisions="canRestoreRevisions"
:is-last="index === group.revisions.length - 1"
Expand Down Expand Up @@ -47,6 +48,7 @@ export default {
props: {
indexUrl: String,
restoreUrl: String,
sharedPreviewUrl: String,
reference: String,
canRestoreRevisions: Boolean,
},
Expand Down
39 changes: 38 additions & 1 deletion resources/js/components/revision-history/Revision.vue
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,18 @@
}[revision.action]
"
/>
<Button
v-if="sharedPreviewUrl"
icon="share-link"
icon-only
size="xs"
variant="ghost"
inset
:aria-label="__('Share Revision')"
v-tooltip="__('Share Revision')"
:loading="copyingPreviewLink"
@click.stop="copyPreviewLink"
/>
</div>

<revision-preview
Expand Down Expand Up @@ -74,7 +86,7 @@
import RestoreRevision from './Restore.vue';
import RevisionPreview from './Preview.vue';
import DateFormatter from '@/components/DateFormatter.js';
import { Subheading, Badge, Avatar } from '@/components/ui';
import { Subheading, Badge, Avatar, Button } from '@/components/ui';

export default {
components: {
Expand All @@ -83,11 +95,13 @@ export default {
Subheading,
Badge,
Avatar,
Button,
},

props: {
revision: Object,
restoreUrl: String,
sharedPreviewUrl: String,
reference: String,
canRestoreRevisions: Boolean,
isLast: Boolean,
Expand All @@ -96,6 +110,7 @@ export default {
data() {
return {
showDetails: false,
copyingPreviewLink: false,
componentProps: {
initialActions: 'actions',
collectionTitle: 'collection.title',
Expand Down Expand Up @@ -134,6 +149,28 @@ export default {

this.showDetails = true;
},

async copyPreviewLink() {
this.copyingPreviewLink = true;

try {
const payload = this.revision.action === 'working' ? {} : { revision: this.revision.date };
const { data } = await this.$axios.post(this.sharedPreviewUrl, payload);

try {
await navigator.clipboard.writeText(data.url);
this.$toast.success(
__('messages.shared_preview_link_copied', { hours: data.expires_in_hours }),
);
} catch {
Statamic.$callbacks.call('copyToClipboard', data.url);
}
} catch {
this.$toast.error(__('Unable to copy preview link'));
} finally {
this.copyingPreviewLink = false;
}
},
},
};
</script>
2 changes: 2 additions & 0 deletions routes/cp.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
use Statamic\Http\Controllers\CP\Collections\EntryActionController;
use Statamic\Http\Controllers\CP\Collections\EntryPreviewController;
use Statamic\Http\Controllers\CP\Collections\EntryRevisionsController;
use Statamic\Http\Controllers\CP\Collections\EntrySharedPreviewController;
use Statamic\Http\Controllers\CP\Collections\LocalizeEntryController;
use Statamic\Http\Controllers\CP\Collections\PublishedEntriesController;
use Statamic\Http\Controllers\CP\Collections\ReorderCollectionBlueprintsController;
Expand Down Expand Up @@ -209,6 +210,7 @@
Route::post('restore-revision', RestoreEntryRevisionController::class)->name('collections.entries.restore-revision');
Route::post('preview', [EntryPreviewController::class, 'edit'])->name('collections.entries.preview.edit');
Route::get('preview', [EntryPreviewController::class, 'show'])->name('collections.entries.preview.popout');
Route::post('shared-preview', [EntrySharedPreviewController::class, 'store'])->name('collections.entries.shared-preview');
Route::patch('/', [EntriesController::class, 'update'])->name('collections.entries.update');
Route::get('{slug}', fn ($collection, $entry, $slug) => redirect($entry->editUrl()));
});
Expand Down
91 changes: 91 additions & 0 deletions src/CP/SharedPreview.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php

namespace Statamic\CP;

use Statamic\Contracts\Tokens\Token as TokenContract;
use Statamic\Facades\Data;
use Statamic\Facades\Token;
use Statamic\Facades\URL;
use Statamic\Tokens\Handlers\SharedPreview as Handler;

class SharedPreview
{
public function tokenize($item, ?int $revision = null): TokenContract
{
if ($existing = $this->existing($item, $revision)) {
return $existing;
}

$minutes = (int) config('statamic.live_preview.shared_link_expiry', 1440);

$data = ['reference' => $item->reference()];

if ($revision) {
$data['revision'] = $revision;
}

return tap(
Token::make(null, Handler::class, $data)->expireAt(now()->addMinutes($minutes))
)->save();
}

public function existing($item, ?int $revision = null): ?TokenContract
{
return Token::all()->first(function ($token) use ($item, $revision) {
return $token->handler() === Handler::class
&& ! $token->hasExpired()
&& $token->get('reference') === $item->reference()
&& $token->get('revision') == $revision;
});
}

public function item(TokenContract $token)
{
$item = Data::find($token->get('reference'));

if (! $item) {
return null;
}

if ($timestamp = $token->get('revision')) {
$revision = $this->findRevision($item, $timestamp);

return $revision ? $item->makeFromRevision($revision) : false;
}

if (
method_exists($item, 'hasWorkingCopy')
&& $item->revisionsEnabled()
&& $item->hasWorkingCopy()
) {
return $item->fromWorkingCopy();
}

return $item;
}

public function url($item, TokenContract $token, int $target = 0): string
{
$preview = $this->item($token);

if (! $preview) {
$preview = $item;
}

$targets = method_exists($preview, 'previewTargets') ? $preview->previewTargets() : collect();
$url = URL::makeAbsolute($targets[$target]['url'] ?? $preview->absoluteUrl());

return $url.(str_contains((string) $url, '?') ? '&' : '?').'token='.$token->token();
}

public function findRevision($item, $timestamp)
{
if (! method_exists($item, 'revisions')) {
return null;
}

return $item->revisions()->first(
fn ($revision) => (int) $revision->date()->timestamp === (int) $timestamp
);
}
}
4 changes: 4 additions & 0 deletions src/Contracts/Tokens/TokenRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@

namespace Statamic\Contracts\Tokens;

use Illuminate\Support\Collection;

interface TokenRepository
{
public function make(?string $token, string $handler, array $data = []): Token;

public function find(string $token): ?Token;

public function all(): Collection;

public function save(Token $token): bool;

public function delete(Token $token): bool;
Expand Down
7 changes: 7 additions & 0 deletions src/Entries/Entry.php
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,13 @@ public function livePreviewUrl()
: null;
}

public function sharedPreviewUrl()
{
return $this->id()
? $this->cpUrl('collections.entries.shared-preview')
: null;
}

protected function cpUrl($route)
{
if (! $id = $this->id()) {
Expand Down
1 change: 1 addition & 0 deletions src/Facades/Token.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
/**
* @method static \Statamic\Contracts\Tokens\Token make(?string $token, string $handler, array $data = [])
* @method static \Statamic\Contracts\Tokens\Token find(string $token)
* @method static \Illuminate\Support\Collection all()
* @method static bool save(\Statamic\Contracts\Tokens\Token $token)
* @method static bool delete(\Statamic\Contracts\Tokens\Token $token)
* @method static void collectGarbage()
Expand Down
1 change: 1 addition & 0 deletions src/Http/Controllers/CP/Collections/EntriesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ public function edit(Request $request, $collection, $entry)
'restore' => $entry->restoreRevisionUrl(),
'createRevision' => $entry->createRevisionUrl(),
'editBlueprint' => cp_route('blueprints.collections.edit', [$collection, $blueprint]),
'sharedPreview' => $entry->sharedPreviewUrl(),
],
'values' => array_merge($values, ['id' => $entry->id()]),
'extraValues' => $extraValues,
Expand Down
Loading
Loading