Initial work on the sandbox

Rough but works
pull/1149/head
Lim Chee Aun 2025-05-12 19:56:44 +08:00
rodzic 0f3a556e9e
commit f7006d71b3
3 zmienionych plików z 922 dodań i 1 usunięć

Wyświetl plik

@ -43,6 +43,7 @@ import Login from './pages/login';
import Mentions from './pages/mentions';
import Notifications from './pages/notifications';
import Public from './pages/public';
import Sandbox from './pages/sandbox';
import ScheduledPosts from './pages/scheduled-posts';
import Search from './pages/search';
import StatusRoute from './pages/status-route';
@ -497,7 +498,7 @@ const PrimaryRoutes = memo(({ isLoggedIn }) => {
const location = useLocation();
const nonRootLocation = useMemo(() => {
const { pathname } = location;
return !/^\/(login|welcome)/i.test(pathname);
return !/^\/(login|welcome|_sandbox)/i.test(pathname);
}, [location]);
return (
@ -505,6 +506,7 @@ const PrimaryRoutes = memo(({ isLoggedIn }) => {
<Route path="/" element={<Root isLoggedIn={isLoggedIn} />} />
<Route path="/login" element={<Login />} />
<Route path="/welcome" element={<Welcome />} />
<Route path="/_sandbox" element={<Sandbox />} />
</Routes>
);
});

Wyświetl plik

@ -0,0 +1,180 @@
#sandbox {
display: grid;
grid-template-rows: auto 1fr 1fr;
width: 100%;
height: 100svh;
background-color: var(--bg-faded-color);
@media (min-width: 40em) {
grid-template-rows: auto 1fr;
grid-template-columns: 1fr min(40%, 320px);
}
header {
display: flex;
align-items: center;
grid-column: 1 / -1;
h1 {
font-size: 1em;
font-weight: normal;
text-transform: uppercase;
margin: 0;
padding: 0;
color: var(--text-insignificant-color);
}
}
+ #compose-button,
~ #shortcuts {
display: none;
}
.sandbox-preview {
position: relative;
padding: 16px;
background-color: var(--bg-color);
/* No need for preview container transition */
/* chess board background, not rotated */
background-image:
linear-gradient(var(--bg-faded-color) 2px, transparent 2px),
linear-gradient(90deg, var(--bg-faded-color) 2px, transparent 2px),
linear-gradient(var(--bg-faded-color) 1px, transparent 1px),
linear-gradient(90deg, var(--bg-faded-color) 1px, transparent 1px);
background-size:
50px 50px,
50px 50px,
10px 10px,
10px 10px;
background-position:
-2px -2px,
-2px -2px,
-1px -1px,
-1px -1px;
overflow: auto;
box-shadow: 0 0 0 1px var(--outline-color);
display: flex;
align-items: safe center;
justify-content: center;
> .status,
> * > .status {
min-width: 320px;
max-width: 40em;
background-color: var(--bg-color);
border-radius: 8px;
box-shadow:
0 4px 16px var(--drop-shadow-color),
0 8px 32px -4px var(--drop-shadow-color);
view-transition-name: status;
.meta {
view-transition-name: status-meta;
}
.avatar {
view-transition-name: status-avatar;
}
.content-container {
view-transition-name: status-content-container;
}
.media-container {
view-transition-name: status-media;
}
.poll {
view-transition-name: status-poll;
}
.status-badge {
view-transition-name: status-badge;
}
.actions {
view-transition-name: status-actions;
}
}
}
.sandbox-toggles {
padding: 16px;
font-size: 0.8em;
overflow: auto;
background-color: var(--bg-blur-color);
box-shadow: 0 0 0 1px var(--outline-color);
h2 {
margin-top: 0;
padding-top: 0;
color: var(--text-insignificant-color);
font-size: 1em;
text-transform: uppercase;
}
h3 {
color: var(--text-insignificant-color);
font-size: 1em;
}
> ul {
display: flex;
flex-direction: column;
row-gap: 8px;
li:has(> label) ul {
padding-inline-start: 24px;
}
}
ul {
margin: 0;
padding: 0;
list-style: none;
ul:not(:has(ul)) {
display: flex;
flex-wrap: wrap;
column-gap: 4px;
li {
padding-inline-start: 0;
}
}
}
li {
margin: 0;
padding: 0;
list-style: none;
}
label {
cursor: pointer;
border-radius: 8px;
&:hover {
background-color: var(--link-bg-color);
}
}
label,
b,
i {
display: flex;
padding: 4px;
gap: 4px;
align-items: center;
}
input[type='number'] {
width: 3em;
text-align: end;
}
input[type='radio' i],
input[type='checkbox' i] {
margin: 0;
}
}
}

Wyświetl plik

@ -0,0 +1,739 @@
import './sandbox.css';
import { useEffect, useState } from 'preact/hooks';
import Status from '../components/status';
import { getPreferences } from '../utils/api';
import FilterContext from '../utils/filter-context';
import store from '../utils/store';
function hashID(obj) {
if (!obj) return '';
if (typeof obj !== 'object') return String(obj);
return Object.entries(obj)
.map(([k, v]) =>
typeof v === 'object' && !Array.isArray(v)
? `${k}:${hashID(v)}`
: `${k}:${v}`,
)
.join('|');
}
const MOCK_STATUS = ({ toggles = {} } = {}) => {
console.log('toggles', toggles);
const {
loading,
mediaFirst,
contentType,
contentFormat,
spoiler,
spoilerType,
mediaCount,
pollCount,
pollMultiple,
pollExpired,
size,
filters,
userPreferences,
} = toggles;
const shortContent = 'This is a test status with short text content.';
const longContent = `<p>This is a test status with long text content. It contains multiple paragraphs and spans several lines to demonstrate how longer content appears.</p>
<p>Second paragraph goes here with more sample text. The Status component will render this appropriately based on the current size setting.</p>
<p>Third paragraph adds even more content to ensure we have a properly long post that might get truncated depending on the view settings.</p>`;
const linksContent = `<p>This is a test status with links. Check out <a href="https://example.com">this website</a> and <a href="https://google.com">Google</a>. Links should be clickable and properly styled.</p>`;
const hashtagsContent = `<p>This is a test status with hashtags. <a href="https://example.social/tags/coding" class="hashtag" rel="tag">#coding</a> <a href="https://example.social/tags/webdev" class="hashtag" rel="tag">#webdev</a> <a href="https://example.social/tags/javascript" class="hashtag" rel="tag">#javascript</a> <a href="https://example.social/tags/reactjs" class="hashtag" rel="tag">#reactjs</a> <a href="https://example.social/tags/preact" class="hashtag" rel="tag">#preact</a></p><p>Hashtags should be formatted and clickable.</p>`;
const mentionsContent = `<p>This is a test status with mentions. Hello <a href="https://example.social/@cheeaun" class="u-url mention">@cheeaun</a> and <a href="https://example.social/@test" class="u-url mention">@test</a>! What do you think about this <a href="https://example.social/@another_user" class="u-url mention">@another_user</a>?</p><p>Mentions should be highlighted and clickable.</p>`;
const base = {
// Random ID to un-memoize Status
id: hashID(toggles),
account: {
username: 'test',
name: 'Test',
// avatar: 'https://picsum.photos/seed/avatar/200',
avatar: '/logo-192.png',
acct: 'test@localhost',
url: 'https://test.localhost',
},
content:
contentFormat === 'text'
? contentType === 'long'
? longContent
: contentType === 'links'
? linksContent
: contentType === 'hashtags'
? hashtagsContent
: contentType === 'mentions'
? mentionsContent
: shortContent
: '',
visibility: 'public',
createdAt: new Date().toISOString(),
reblogsCount: 0,
favouritesCount: 0,
repliesCount: 5,
emojis: [],
mentions: [],
tags: [],
mediaAttachments: [],
};
// Add media if selected
if (mediaCount > 0) {
base.mediaAttachments = Array(parseInt(mediaCount, 10))
.fill(0)
.map((_, i) => ({
id: `media-${i}`,
type: 'image',
url: `https://picsum.photos/seed/media-${i}/600/400`,
previewUrl: `https://picsum.photos/seed/media-${i}/300/200`,
description:
i % 2 === 0 ? `Sample image description for media ${i + 1}` : '',
meta: {
original: {
width: 600,
height: 400,
},
small: {
width: 600,
height: 400,
},
},
}));
}
// Add poll if selected
if (pollCount > 0) {
base.poll = {
id: 'poll-1',
options: Array(parseInt(pollCount, 10))
.fill(0)
.map((_, i) => ({
title: `Option ${i + 1}`,
votesCount: Math.floor(Math.random() * 100),
})),
// Set expiration date in the past if poll is expired, otherwise in the future
expiresAt: pollExpired
? new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString() // 24 hours ago
: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), // 24 hours from now
expired: pollExpired,
multiple: pollMultiple,
// Use votersCount for multiple-choice polls, votesCount for single-choice polls
votesCount: 150,
votersCount: pollMultiple ? 100 : undefined,
voted: false,
};
}
// Add spoiler if selected
if (spoiler) {
base.sensitive = true;
base.spoilerText = 'Content warning: test spoiler';
if (spoilerType === 'mediaOnly') {
// For media-only spoiler, remove spoilerText but keep sensitive true
base.spoilerText = '';
}
}
// Add mentions and tags if needed
if (contentType === 'mentions') {
base.mentions = [
{
id: '1',
username: 'cheeaun',
url: 'https://example.social/@cheeaun',
acct: 'cheeaun',
},
{
id: '2',
username: 'test',
url: 'https://example.social/@test',
acct: 'test',
},
{
id: '3',
username: 'another_user',
url: 'https://example.social/@another_user',
acct: 'another_user',
},
];
}
if (contentType === 'hashtags') {
base.tags = [
{
name: 'coding',
url: 'https://example.social/tags/coding',
},
{
name: 'webdev',
url: 'https://example.social/tags/webdev',
},
{
name: 'javascript',
url: 'https://example.social/tags/javascript',
},
{
name: 'reactjs',
url: 'https://example.social/tags/reactjs',
},
{
name: 'preact',
url: 'https://example.social/tags/preact',
},
];
}
// Add any relevant filtered flags based on filter settings
if (filters && filters.some((f) => f)) {
base.filtered = filters
.map((enabled, i) => {
if (!enabled) return null;
const filterTypes = ['hide', 'blur', 'warn'];
return {
filter: {
id: `filter-${i}`,
title: `Sample ${filterTypes[i]} filter`,
context: ['home', 'public', 'thread', 'account'],
filterAction: filterTypes[i],
},
keywordMatches: [],
statusMatches: [],
};
})
.filter(Boolean);
}
console.log('Final base', base);
return base;
};
export default function Sandbox() {
// Consolidated state for all toggles
const [toggleState, setToggleState] = useState({
loading: false,
mediaFirst: false,
hasContent: true,
contentType: 'short',
hasSpoiler: false,
spoilerType: 'all',
mediaCount: '0',
pollCount: '0',
pollMultiple: false,
pollExpired: false,
size: 'medium',
filters: [false, false, false], // hide, blur, warn
mediaPreference: 'default',
expandWarnings: false,
});
// Update function with view transitions
const updateToggles = (updates) => {
// Check for browser support
if (!document.startViewTransition) {
setToggleState((prev) => ({ ...prev, ...updates }));
return;
}
// Use view transition API
document.startViewTransition(() => {
setToggleState((prev) => ({ ...prev, ...updates }));
});
};
// Set up preference stubbing
useEffect(() => {
console.log('User preference updated:', {
mediaPreference: toggleState.mediaPreference,
expandWarnings: toggleState.expandWarnings,
});
// Create a backup of the original method
const originalGet = store.account.get;
// Stub the store.account.get method to return our custom preferences
store.account.get = (key) => {
if (key === 'preferences') {
console.log('Preferences requested, returning:', {
'reading:expand:media': toggleState.mediaPreference,
'reading:expand:spoilers': toggleState.expandWarnings,
});
return {
'reading:expand:media': toggleState.mediaPreference,
'reading:expand:spoilers': toggleState.expandWarnings,
};
}
return originalGet.call(store.account, key);
};
// Clear the getPreferences cache to ensure our new preferences are used
getPreferences.clear();
// Restore the original method when the component unmounts
return () => {
store.account.get = originalGet;
getPreferences.clear();
};
}, [toggleState.mediaPreference, toggleState.expandWarnings]);
// Generate status with current toggle values
const mockStatus = MOCK_STATUS({
toggles: {
loading: toggleState.loading,
mediaFirst: toggleState.mediaFirst,
contentFormat: toggleState.hasContent ? 'text' : null,
contentType: toggleState.contentType,
spoiler: toggleState.hasSpoiler,
spoilerType: toggleState.spoilerType,
mediaCount: toggleState.mediaCount,
pollCount: toggleState.pollCount,
pollMultiple: toggleState.pollMultiple,
pollExpired: toggleState.pollExpired,
size: toggleState.size,
filters: toggleState.filters,
},
});
// Handler for filter checkboxes
const handleFilterChange = (index) => {
const newFilters = [...toggleState.filters];
newFilters[index] = !newFilters[index];
updateToggles({ filters: newFilters });
};
return (
<main id="sandbox">
<header>
<a href="#/" class="button plain4">
×
</a>
<h1>Sandbox</h1>
</header>
<div class="sandbox-preview">
<FilterContext.Provider value={'home'}>
{toggleState.loading ? (
<Status
skeleton
mediaFirst={toggleState.mediaFirst}
key={`skeleton-${toggleState.mediaFirst}`}
/>
) : (
<Status
status={mockStatus}
mediaFirst={toggleState.mediaFirst}
size={
toggleState.size === 'small'
? 's'
: toggleState.size === 'medium'
? 'm'
: 'l'
}
allowFilters={true}
// Add a key that changes when preferences change to force re-render
key={`status-${toggleState.mediaPreference}-${toggleState.expandWarnings}-${mockStatus.id}`}
/>
)}
</FilterContext.Provider>
</div>
<form class="sandbox-toggles" onSubmit={(e) => e.preventDefault()}>
<h2>Toggles</h2>
<ul>
<li>
<b>Miscellaneous</b>
<ul>
<li>
<label>
<input
type="checkbox"
checked={toggleState.loading}
onChange={() =>
updateToggles({ loading: !toggleState.loading })
}
/>
<span>Loading</span>
</label>
</li>
<li>
<label>
<input
type="checkbox"
checked={toggleState.mediaFirst}
onChange={() =>
updateToggles({ mediaFirst: !toggleState.mediaFirst })
}
/>
<span>Media first</span>
</label>
</li>
</ul>
</li>
<li>
<b>Content</b>
<ul>
<li>
<label>
<input
type="checkbox"
checked={toggleState.hasContent}
onChange={() => {
// Create the update object
const updates = { hasContent: !toggleState.hasContent };
// If turning off text and no media, then add media
if (
toggleState.hasContent &&
parseInt(toggleState.mediaCount) === 0
) {
updates.mediaCount = '1';
}
// Apply all updates in one transition
updateToggles(updates);
}}
disabled={parseInt(toggleState.mediaCount) === 0}
/>
<span>Text</span>
</label>
<ul>
<li>
<label>
<input
type="radio"
name="contentType"
checked={toggleState.contentType === 'short'}
onChange={() => updateToggles({ contentType: 'short' })}
disabled={!toggleState.hasContent}
/>
<span>Short</span>
</label>
</li>
<li>
<label>
<input
type="radio"
name="contentType"
checked={toggleState.contentType === 'long'}
onChange={() => updateToggles({ contentType: 'long' })}
disabled={!toggleState.hasContent}
/>
<span>Long</span>
</label>
</li>
<li>
<label>
<input
type="radio"
name="contentType"
checked={toggleState.contentType === 'links'}
onChange={() => updateToggles({ contentType: 'links' })}
disabled={!toggleState.hasContent}
/>
<span>With links</span>
</label>
</li>
<li>
<label>
<input
type="radio"
name="contentType"
checked={toggleState.contentType === 'hashtags'}
onChange={() =>
updateToggles({ contentType: 'hashtags' })
}
disabled={!toggleState.hasContent}
/>
<span>With hashtags</span>
</label>
</li>
<li>
<label>
<input
type="radio"
name="contentType"
checked={toggleState.contentType === 'mentions'}
onChange={() =>
updateToggles({ contentType: 'mentions' })
}
disabled={!toggleState.hasContent}
/>
<span>With mentions</span>
</label>
</li>
</ul>
</li>
<li>
<label>
<input
type="checkbox"
checked={toggleState.hasSpoiler}
onChange={() =>
updateToggles({ hasSpoiler: !toggleState.hasSpoiler })
}
/>
<span>Content warning</span>
</label>
<ul>
<li>
<label>
<input
type="radio"
name="spoilerType"
checked={toggleState.spoilerType === 'all'}
onChange={() => updateToggles({ spoilerType: 'all' })}
/>
<span>Whole content</span>
</label>
</li>
<li>
<label>
<input
type="radio"
name="spoilerType"
checked={toggleState.spoilerType === 'mediaOnly'}
onChange={() =>
updateToggles({ spoilerType: 'mediaOnly' })
}
/>
<span>Media only</span>
</label>
</li>
</ul>
</li>
<li>
<label>
<input
type="checkbox"
checked={parseInt(toggleState.mediaCount) > 0}
onChange={(e) => {
const newHasMedia = e.target.checked;
const updates = {
mediaCount: newHasMedia ? '1' : '0',
};
// If removing media and no text content, enable text content
if (!newHasMedia && !toggleState.hasContent) {
updates.hasContent = true;
}
updateToggles(updates);
}}
/>
<span>Media</span>
<input
type="number"
min="1"
value={
toggleState.mediaCount === '0'
? '1'
: toggleState.mediaCount
}
step="1"
onChange={(e) =>
updateToggles({ mediaCount: e.target.value })
}
disabled={parseInt(toggleState.mediaCount) === 0}
/>
</label>
</li>
<li>
<label>
<input
type="checkbox"
checked={parseInt(toggleState.pollCount) > 0}
onChange={(e) => {
const updates = {
pollCount: e.target.checked ? '2' : '0',
};
// Reset multiple to false when disabling poll
if (!e.target.checked) {
updates.pollMultiple = false;
}
updateToggles(updates);
}}
/>
<span>Poll</span>
<input
type="number"
min="2"
value={toggleState.pollCount}
step="1"
onChange={(e) =>
updateToggles({ pollCount: e.target.value })
}
disabled={parseInt(toggleState.pollCount) === 0}
/>
<label>
<input
type="checkbox"
checked={toggleState.pollMultiple}
onChange={() =>
updateToggles({
pollMultiple: !toggleState.pollMultiple,
})
}
disabled={parseInt(toggleState.pollCount) === 0}
/>
<span>Multiple</span>
</label>
<label>
<input
type="checkbox"
checked={toggleState.pollExpired}
onChange={() =>
updateToggles({ pollExpired: !toggleState.pollExpired })
}
disabled={parseInt(toggleState.pollCount) === 0}
/>
<span>Expired</span>
</label>
</label>
</li>
</ul>
</li>
<li>
<b>Size</b>
<ul>
<li>
<label>
<input
type="radio"
name="size"
checked={toggleState.size === 'small'}
onChange={() => updateToggles({ size: 'small' })}
/>
<span>Small</span>
</label>
</li>
<li>
<label>
<input
type="radio"
name="size"
checked={toggleState.size === 'medium'}
onChange={() => updateToggles({ size: 'medium' })}
/>
<span>Medium</span>
</label>
</li>
<li>
<label>
<input
type="radio"
name="size"
checked={toggleState.size === 'large'}
onChange={() => updateToggles({ size: 'large' })}
/>
<span>Large</span>
</label>
</li>
</ul>
</li>
<li>
<b>Filters</b>
<ul>
<li>
<label>
<input
type="checkbox"
checked={toggleState.filters[0]}
onChange={() => handleFilterChange(0)}
/>
<span>Hide</span>
</label>
</li>
<li>
<label>
<input
type="checkbox"
checked={toggleState.filters[1]}
onChange={() => handleFilterChange(1)}
/>
<span>Blur</span>
</label>
</li>
<li>
<label>
<input
type="checkbox"
checked={toggleState.filters[2]}
onChange={() => handleFilterChange(2)}
/>
<span>Warn</span>
</label>
</li>
</ul>
</li>
<li>
<h3>User preferences for sensitive content</h3>
<ul>
<li>
<b>Media display</b>
<ul>
<li>
<label>
<input
type="radio"
name="mediaPreference"
checked={toggleState.mediaPreference === 'default'}
onChange={() =>
updateToggles({ mediaPreference: 'default' })
}
/>
<span>Hide media marked as sensitive</span>
</label>
</li>
<li>
<label>
<input
type="radio"
name="mediaPreference"
checked={toggleState.mediaPreference === 'show_all'}
onChange={() =>
updateToggles({ mediaPreference: 'show_all' })
}
/>
<span>Always show media</span>
</label>
</li>
<li>
<label>
<input
type="radio"
name="mediaPreference"
checked={toggleState.mediaPreference === 'hide_all'}
onChange={() =>
updateToggles({ mediaPreference: 'hide_all' })
}
/>
<span>Always hide media</span>
</label>
</li>
</ul>
</li>
<li>
<label>
<input
type="checkbox"
checked={toggleState.expandWarnings}
onChange={() =>
updateToggles({
expandWarnings: !toggleState.expandWarnings,
})
}
/>{' '}
<span>Always expand posts marked with content warnings</span>
</label>
</li>
</ul>
</li>
</ul>
</form>
</main>
);
}