@@ -59,26 +59,111 @@ export const SidebarArticles = ({ items, sortType, articleState }: Props) => {
5959 localStorage . setItem ( StorageName [ articleState ] , isDetailsOpen . toString ( ) ) ;
6060 } , [ isDetailsOpen ] ) ;
6161
62+ // build recursive tree from item.parent (segments array)
63+ const topLevelItems : ItemViewModel [ ] = [ ] ;
64+
65+ type TreeNode = {
66+ name : string ;
67+ items : ItemViewModel [ ] ;
68+ children : { [ name : string ] : TreeNode } ;
69+ } ;
70+
71+ const roots : { [ name : string ] : TreeNode } = { } ;
72+
73+ const addToTree = ( segments : string [ ] , item : ItemViewModel ) => {
74+ const rootName = segments [ 0 ] ;
75+ if ( ! roots [ rootName ] )
76+ roots [ rootName ] = { name : rootName , items : [ ] , children : { } } ;
77+ let node = roots [ rootName ] ;
78+ const rest = segments . slice ( 1 ) ;
79+ if ( rest . length === 0 ) {
80+ node . items . push ( item ) ;
81+ return ;
82+ }
83+ for ( const seg of rest ) {
84+ if ( ! node . children [ seg ] )
85+ node . children [ seg ] = { name : seg , items : [ ] , children : { } } ;
86+ node = node . children [ seg ] ;
87+ }
88+ node . items . push ( item ) ;
89+ } ;
90+
91+ items . forEach ( ( item ) => {
92+ if ( ! item . parent || item . parent . length === 0 ) {
93+ topLevelItems . push ( item ) ;
94+ } else {
95+ addToTree ( item . parent , item ) ;
96+ }
97+ } ) ;
98+
99+ const countSubtreeItems = ( node : TreeNode ) : number =>
100+ node . items . length +
101+ Object . values ( node . children ) . reduce ( ( s , c ) => s + countSubtreeItems ( c ) , 0 ) ;
102+
103+ const renderNode = ( node : TreeNode , path : string ) => {
104+ const cmp = compare [ sortType ] ;
105+ return (
106+ < li key = { path } >
107+ < details css = { articleDetailsStyle } open >
108+ < summary css = { articleSummaryStyle } >
109+ { node . name }
110+ < span css = { articleSectionTitleCountStyle } >
111+ { countSubtreeItems ( node ) }
112+ </ span >
113+ </ summary >
114+ < ul >
115+ { Object . values ( node . children )
116+ . sort ( ( a , b ) => a . name . localeCompare ( b . name ) )
117+ . map ( ( child ) => renderNode ( child , `${ path } /${ child . name } ` ) ) }
118+
119+ { [ ...node . items ] . sort ( cmp ) . map ( ( item ) => (
120+ < li key = { item . items_show_path } >
121+ < Link css = { articlesListItemStyle } to = { item . items_show_path } >
122+ < MaterialSymbol
123+ fill = { item . modified && articleState !== "Draft" }
124+ >
125+ note
126+ </ MaterialSymbol >
127+ < span css = { articleListItemInnerStyle } >
128+ { item . modified && articleState !== "Draft" && "(差分あり) " }
129+ { item . title }
130+ </ span >
131+ </ Link >
132+ </ li >
133+ ) ) }
134+ </ ul >
135+ </ details >
136+ </ li >
137+ ) ;
138+ } ;
139+
62140 return (
63141 < details css = { articleDetailsStyle } open = { isDetailsOpen } >
64142 < summary css = { articleSummaryStyle } onClick = { toggleAccordion } >
65143 { ArticleState [ articleState ] }
66144 < span css = { articleSectionTitleCountStyle } > { items . length } </ span >
67145 </ summary >
68146 < ul >
69- { items . sort ( compare [ sortType ] ) . map ( ( item ) => (
70- < li key = { item . items_show_path } >
71- < Link css = { articlesListItemStyle } to = { item . items_show_path } >
72- < MaterialSymbol fill = { item . modified && articleState !== "Draft" } >
73- note
74- </ MaterialSymbol >
75- < span css = { articleListItemInnerStyle } >
76- { item . modified && articleState !== "Draft" && "(差分あり) " }
77- { item . title }
78- </ span >
79- </ Link >
80- </ li >
81- ) ) }
147+ { Object . values ( roots )
148+ . sort ( ( a , b ) => a . name . localeCompare ( b . name ) )
149+ . map ( ( r ) => renderNode ( r , r . name ) ) }
150+
151+ { topLevelItems . length > 0 &&
152+ [ ...topLevelItems ] . sort ( compare [ sortType ] ) . map ( ( item ) => (
153+ < li key = { item . items_show_path } >
154+ < Link css = { articlesListItemStyle } to = { item . items_show_path } >
155+ < MaterialSymbol
156+ fill = { item . modified && articleState !== "Draft" }
157+ >
158+ note
159+ </ MaterialSymbol >
160+ < span css = { articleListItemInnerStyle } >
161+ { item . modified && articleState !== "Draft" && "(差分あり) " }
162+ { item . title }
163+ </ span >
164+ </ Link >
165+ </ li >
166+ ) ) }
82167 </ ul >
83168 </ details >
84169 ) ;
@@ -93,6 +178,44 @@ const articleDetailsStyle = css({
93178 "&[open] > summary::before" : {
94179 content : "'expand_more'" ,
95180 } ,
181+ // nested lists: draw vertical guide lines inside the padded area
182+ "& ul" : {
183+ listStyle : "none" ,
184+ margin : 0 ,
185+ paddingLeft : getSpace ( 1 ) ,
186+ } ,
187+ "& ul ul" : {
188+ position : "relative" ,
189+ paddingLeft : getSpace ( 3 ) ,
190+ } ,
191+ "& ul ul::before" : {
192+ content : "''" ,
193+ position : "absolute" ,
194+ left : getSpace ( 3 ) ,
195+ top : 0 ,
196+ bottom : 0 ,
197+ width : 1 ,
198+ backgroundColor : Colors . gray20 ,
199+ } ,
200+ "& ul ul > li" : {
201+ paddingLeft : getSpace ( 1.5 ) ,
202+ } ,
203+ "& ul ul ul" : {
204+ position : "relative" ,
205+ paddingLeft : getSpace ( 4 ) ,
206+ } ,
207+ "& ul ul ul::before" : {
208+ content : "''" ,
209+ position : "absolute" ,
210+ left : getSpace ( 3 ) ,
211+ top : 0 ,
212+ bottom : 0 ,
213+ width : 1 ,
214+ backgroundColor : Colors . gray20 ,
215+ } ,
216+ "& ul ul ul > li" : {
217+ paddingLeft : getSpace ( 1.5 ) ,
218+ } ,
96219} ) ;
97220
98221const articleSummaryStyle = css ( {
@@ -137,9 +260,9 @@ const articlesListItemStyle = css({
137260 fontSize : Typography . body2 ,
138261 gap : getSpace ( 1 ) ,
139262 lineHeight : LineHeight . bodyDense ,
140- padding : `${ getSpace ( 3 / 4 ) } px ${ getSpace ( 5 / 2 ) } px ${ getSpace (
141- 3 / 4 ,
142- ) } px ${ getSpace ( 3 / 2 ) } px `,
263+ padding : `${ getSpace ( 3 / 4 ) } px ${ getSpace ( 5 / 2 ) } px ${ getSpace ( 3 / 4 ) } px ${ getSpace (
264+ 3 ,
265+ ) } px`,
143266 whiteSpace : "nowrap" ,
144267 textOverflow : "ellipsis" ,
145268
0 commit comments