소스 검색

Honour a series' tiled setting in guests and in generated post types

guests.tx ignored $tiled entirely, and the wizard's generated wrapper never
carried the tile class, so a wizard-built type was the one kind of post that
silently disregarded the setting. Both fixed: the generated wrapper is now

    <div class="post <: $style :> <: $tiled ? 'tile' : '' :>">

and guests.tx grows a compact branch like file.tx and series.tx have, showing
the console and the state without the editing chrome. The wizard's help text
mentions $tiled, since an author writing a display template is the only one who
can decide what the compact view should contain.

Also stops a post being saved with no visibility at all. _process pushes
visibility into the tags, so an undef one put an undef *in* the tags -- a tag
no query matches, which quietly makes the post invisible to everyone but an
admin. Any post type built without the visibility include produced exactly
that; the hypervisor post in this instance is stored with
tags=["hypervisors", undef] for precisely that reason, which is why the guests
series 404s for logged-out visitors. Defaults to private rather than public,
because guessing wrong the other way publishes something nobody asked to
publish.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U3k2hhSZpcsoe6uBmhF9nB
George Baugh 2 달 전
부모
커밋
24d1e1ad6c

+ 10 - 2
lib/Trog/DataModule.pm

@@ -151,7 +151,7 @@ sub _fixup ( $self, @filtered ) {
         $subj->{display_name} = $user2display{ $subj->{user} };
 
         #XXX Add dynamic routing data for posts which don't have them (/posts/$id) and (/users/$user)
-        my $is_user_page = List::Util::any { $_ eq 'about' } @{ $subj->{tags} };
+        my $is_user_page = List::Util::any { ($_ // '') eq 'about' } @{ $subj->{tags} };
         if ( !exists $subj->{local_href} ) {
             $subj->{local_href} = "/posts/$subj->{id}";
 
@@ -222,7 +222,7 @@ sub filter ( $self, $query, @filtered ) {
         my $tags = $_->{tags};
         grep {
             my $t = $_;
-            grep { $t eq $_ } @{ $query->{tags} }
+            grep { ($t // '') eq $_ } @{ $query->{tags} }
         } @$tags
     } @filtered if @{ $query->{tags} };
 
@@ -613,6 +613,14 @@ sub add ( $self, @posts ) {
             push( @{ $post->{aliases} }, "/posts/$post->{id}", "/series/$post->{id}" );
         }
 
+        # Every post needs one: _process pushes it into the tags, and an
+        # undef there is a tag no query will ever match, which makes the post
+        # invisible to everyone but an admin.  A post type built without a
+        # visibility selector would otherwise produce exactly that.  Private
+        # rather than public, because guessing wrong in the other direction
+        # publishes something nobody asked to publish.
+        $post->{visibility} //= 'private';
+
         $post->{callback} //= 'Trog::Routes::HTML::posts';
 
         # If this is a user creation post, add in the /user/ route

+ 5 - 1
lib/Trog/Routes/HTML.pm

@@ -2416,7 +2416,11 @@ sub _wizard_template ( $query, $fields, $includes, $body_form ) {
     my $display = _wizard_scalar( $query->{display} );
 
     my $out = "<!-- Generated by the tCMS Post Type Wizard.  Regenerating this type will overwrite any hand edits. -->\n";
-    $out .= qq|<div class="post <: \$style :>">\n| if $query->{wrapper};
+    # A series can ask for its children to be tiled, which is a layout the
+    # wrapper has to carry -- see how file.tx and series.tx do it.  Harmless
+    # when the series hasn't asked, and it means a generated type gets tiling
+    # for free rather than being the one kind of post that ignores the setting.
+    $out .= qq|<div class="post <: \$style :> <: \$tiled ? 'tile' : '' :>">\n| if $query->{wrapper};
     $out .= qq|    : if ( !\$post.addpost ) {\n|;
     $out .= qq|        : include "post_title.tx";\n| if $query->{inc_post_title};
     $out .= qq|        : include "post_tags.tx";\n|  if $query->{inc_post_tags};

+ 23 - 0
t/Trog-DataModule-schema.t

@@ -209,6 +209,29 @@ subtest 'add() rejects a post that does not validate' => sub {
     is( $written->[0]{servings}, 4, "the good one went through, coerced" );
 };
 
+subtest 'a post always ends up with a visibility' => sub {
+    set_sidecars();
+
+    my $written;
+    {
+        no warnings qw{once};
+        @VisData::ISA = ('Trog::DataModule');
+        *VisData::get   = sub { return () };
+        *VisData::write = sub { $written = $_[1]; return 0 };
+    }
+
+    # A post type built without a visibility selector submits none.  _process
+    # pushes visibility into the tags, so an undef one puts an undef in there
+    # and the post becomes invisible to everyone but an admin.
+    bless( {}, 'VisData' )->add( { title => 'No Visibility', tags => ['sometag'] } );
+
+    is( $written->[0]{visibility}, 'private', 'it defaults, rather than staying undef' );
+    ok( !( grep { !defined $_ } @{ $written->[0]{tags} } ), 'so no undef finds its way into the tags' );
+
+    bless( {}, 'VisData' )->add( { title => 'Explicit', visibility => 'public', tags => ['sometag'] } );
+    is( $written->[0]{visibility}, 'public', 'and an explicit one is left alone' );
+};
+
 subtest '_wizard_fields zips the parallel arrays' => sub {
 
     # The middle row was left blank.  Dropping it must not shift the type of

+ 11 - 0
t/Trog-Routes-HTML-series-lifecycle.t

@@ -424,6 +424,17 @@ subtest 'the post wizard invents a new post type' => sub {
     like( $body, qr/name="param_relation_form"/, 'the relation target select is in the row template' );
     like( $body, qr/name="param_relation_mode"/, 'so is the relation mode select' );
     like( $body, qr/<option value="relation">/,  'and relation is offered as a field type' );
+
+    # A series can ask for its children to be tiled.  A generated type has to
+    # carry that on its wrapper like the hand-written forms do, or it is the
+    # one kind of post that silently ignores the setting.
+    my $generated = Path::Tiny->new('www/templates/html/components/forms/spec_widget.tx')->slurp_utf8;
+
+    # Single quoted: the wrapper is Kolon source, so every sigil in it is
+    # literal and must not be interpolated by the test that checks for it.
+    my $wrapper = q{<div class="post <: $style :> <: $tiled ? 'tile' : '' :>">};
+    ok( index( $generated, $wrapper ) >= 0, 'the generated wrapper carries the tile class when the series asks for it' )
+      or diag( "wanted: $wrapper\ngot:    " . ( split( /\n/, $generated ) )[1] );
 };
 
 #--------------------------------------------------------------------------

+ 35 - 25
www/templates/html/components/forms/guests.tx

@@ -1,4 +1,4 @@
-<div class="post <: $style :>">
+<div class="post <: $style :> <: $tiled ? 'tile' : '' :>">
     : if ( !$post.addpost ) {
         : if ( $post.unreachable ) {
             <h3 class='blogtitles'><: $post.title :></h3>
@@ -6,31 +6,41 @@
                 Could not reach this hypervisor: <: $post.state :>
             </div>
         : } else {
-            <h3 class='blogtitles'>
-                <: $post.title :>
-                <span class="guest-state guest-<: $post.is_active ? 'up' : 'down' :>"><: $post.state :></span>
-            </h3>
-            <div class="postData responsive-text" id="postData-<: $post.id :>">
-                <img src="<: $post.preview :>" class="responsive-img guest-screenshot" alt="console of <: $post.title :>" loading="lazy" />
-                <br />
-                on <: $post.hypervisor_title :> &mdash; <: $post.vcpus :> vCPU
-                : if ( $can_edit ) {
-                <br />
-                <form class="Submissions guest-actions" action="/guest/act" method="POST">
-                    <input type="hidden" name="hypervisor" value="<: $post.hypervisor :>" />
-                    <input type="hidden" name="guest" value="<: $post.domain :>" />
-                    <input type="hidden" name="to" value="<: $route :>" />
-                    : if ( $post.is_active ) {
-                    <button class="coolbutton" type="submit" name="action" value="poweroff">Power Off</button>
-                    : } else {
-                    <button class="coolbutton" type="submit" name="action" value="poweron">Power On</button>
+            : if ( $tiled ) {
+                <a href="<: $post.preview :>" class="undecorated" title="<: $post.title :> (<: $post.state :>)">
+                    <img src="<: $post.preview :>" class="responsive-img guest-screenshot" alt="console of <: $post.title :>" loading="lazy" />
+                    <p class="midtitle">
+                        <: $post.title :>
+                        <span class="guest-state guest-<: $post.is_active ? 'up' : 'down' :>"><: $post.state :></span>
+                    </p>
+                </a>
+            : } else {
+                <h3 class='blogtitles'>
+                    <: $post.title :>
+                    <span class="guest-state guest-<: $post.is_active ? 'up' : 'down' :>"><: $post.state :></span>
+                </h3>
+                <div class="postData responsive-text" id="postData-<: $post.id :>">
+                    <img src="<: $post.preview :>" class="responsive-img guest-screenshot" alt="console of <: $post.title :>" loading="lazy" />
+                    <br />
+                    on <: $post.hypervisor_title :> &mdash; <: $post.vcpus :> vCPU
+                    : if ( $can_edit ) {
+                    <br />
+                    <form class="Submissions guest-actions" action="/guest/act" method="POST">
+                        <input type="hidden" name="hypervisor" value="<: $post.hypervisor :>" />
+                        <input type="hidden" name="guest" value="<: $post.domain :>" />
+                        <input type="hidden" name="to" value="<: $route :>" />
+                        : if ( $post.is_active ) {
+                        <button class="coolbutton" type="submit" name="action" value="poweroff">Power Off</button>
+                        : } else {
+                        <button class="coolbutton" type="submit" name="action" value="poweron">Power On</button>
+                        : }
+                        <button class="coolbutton" type="submit" name="action" value="snapshot">Snapshot</button>
+                        <button class="coolbutton guest-destroy" type="submit" name="action" value="destroy"
+                                onclick="return confirm('Destroy <: $post.title :>?  This stops the guest immediately.');">Destroy</button>
+                    </form>
                     : }
-                    <button class="coolbutton" type="submit" name="action" value="snapshot">Snapshot</button>
-                    <button class="coolbutton guest-destroy" type="submit" name="action" value="destroy"
-                            onclick="return confirm('Destroy <: $post.title :>?  This stops the guest immediately.');">Destroy</button>
-                </form>
-                : }
-            </div>
+                </div>
+            : }
         : }
     : }
 </div>

+ 5 - 0
www/templates/html/components/post_wizard.tx

@@ -67,6 +67,11 @@ any custom fields, so that the data model knows to keep them when a post is save
     <p class="title">Display Template</p>
     <a href="https://metacpan.org/pod/Text::Xslate::Syntax::Kolon" title="GET UR MIND RITE">Kolon</a>
     markup for how this post type renders when someone looks at it.
+    <br />
+    <code>&lt;: $tiled :&gt;</code> is true when the series asked for its posts to be tiled, so branch on it
+    if this type wants a compact view &mdash; see <code>forms/file.tx</code> for the shape.
+    The wrapper gets the <code>tile</code> class either way.
+    Note that it is only ever true for logged-out visitors: an editor needs the full view.
     Use <code>&lt;: $post.yourfield :&gt;</code> for your custom fields, and
     <code>&lt;: render_it($post.data) | mark_raw :&gt;</code> for the post body.
     <br />