diff --git a/src/app.jsx b/src/app.jsx index 0ca54ab1..c2f42cc1 100644 --- a/src/app.jsx +++ b/src/app.jsx @@ -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 }) => { } /> } /> } /> + } /> ); }); diff --git a/src/pages/sandbox.css b/src/pages/sandbox.css new file mode 100644 index 00000000..43f6e0a3 --- /dev/null +++ b/src/pages/sandbox.css @@ -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; + } + } +} diff --git a/src/pages/sandbox.jsx b/src/pages/sandbox.jsx new file mode 100644 index 00000000..ec6e548c --- /dev/null +++ b/src/pages/sandbox.jsx @@ -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 = `

This is a test status with long text content. It contains multiple paragraphs and spans several lines to demonstrate how longer content appears.

+ +

Second paragraph goes here with more sample text. The Status component will render this appropriately based on the current size setting.

+ +

Third paragraph adds even more content to ensure we have a properly long post that might get truncated depending on the view settings.

`; + const linksContent = `

This is a test status with links. Check out this website and Google. Links should be clickable and properly styled.

`; + const hashtagsContent = `

This is a test status with hashtags.

Hashtags should be formatted and clickable.

`; + const mentionsContent = `

This is a test status with mentions. Hello @cheeaun and @test! What do you think about this @another_user?

Mentions should be highlighted and clickable.

`; + + 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 ( +
+
+ + × + +

Sandbox

+
+
+ + {toggleState.loading ? ( + + ) : ( + + )} + +
+
e.preventDefault()}> +

Toggles

+
    +
  • + Miscellaneous +
      +
    • + +
    • +
    • + +
    • +
    +
  • +
  • + Content +
      +
    • + +
        +
      • + +
      • +
      • + +
      • +
      • + +
      • +
      • + +
      • +
      • + +
      • +
      +
    • +
    • + +
        +
      • + +
      • +
      • + +
      • +
      +
    • +
    • + +
    • +
    • + +
    • +
    +
  • +
  • + Size +
      +
    • + +
    • +
    • + +
    • +
    • + +
    • +
    +
  • +
  • + Filters +
      +
    • + +
    • +
    • + +
    • +
    • + +
    • +
    +
  • +
  • +

    User preferences for sensitive content

    +
      +
    • + Media display +
        +
      • + +
      • +
      • + +
      • +
      • + +
      • +
      +
    • +
    • + +
    • +
    +
  • +
+
+
+ ); +}