FolderReadmePanel.tsx 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148
  1. import { useEffect, useState } from 'react';
  2. import { useQuery } from '@tanstack/react-query';
  3. import { useTranslation } from 'react-i18next';
  4. import { FileText, PanelRightClose, PanelRightOpen } from 'lucide-react';
  5. import ReactMarkdown from 'react-markdown';
  6. import remarkGfm from 'remark-gfm';
  7. import { api } from '../api/client';
  8. interface FolderReadmePanelProps {
  9. folderId: number;
  10. }
  11. // Persist the collapsed choice so hiding the README once keeps it hidden
  12. // across folder switches and page reloads (#2520 item 2).
  13. const COLLAPSE_STORAGE_KEY = 'fileManager.readmeCollapsed';
  14. /**
  15. * Markdown panel for the selected folder (#1268).
  16. *
  17. * Docks as a collapsible right-hand rail on wide screens so the README sits
  18. * *beside* the file list instead of pushing it down and eating vertical
  19. * space (#2520 item 2); on narrow screens it stacks above the list
  20. * (`order-first`) where the page itself scrolls. Collapsing shrinks it to a
  21. * thin strip (desktop) / slim bar (mobile) with a one-click reopen, and the
  22. * choice is persisted. Auto-hidden when the folder has no markdown. Raw HTML
  23. * is disabled and links stay text-only — same posture as the print-archive
  24. * note panel.
  25. */
  26. export function FolderReadmePanel({ folderId }: FolderReadmePanelProps) {
  27. const { t } = useTranslation();
  28. const [collapsed, setCollapsed] = useState<boolean>(
  29. () => localStorage.getItem(COLLAPSE_STORAGE_KEY) === '1',
  30. );
  31. useEffect(() => {
  32. localStorage.setItem(COLLAPSE_STORAGE_KEY, collapsed ? '1' : '0');
  33. }, [collapsed]);
  34. const { data, isLoading, error } = useQuery({
  35. queryKey: ['folder-readme', folderId],
  36. queryFn: () => api.getLibraryFolderReadme(folderId),
  37. retry: false,
  38. staleTime: 30_000,
  39. });
  40. if (isLoading || error || !data) return null;
  41. if (collapsed) {
  42. return (
  43. <div className="mb-2 lg:mb-0 order-first lg:order-none lg:w-10 lg:flex-shrink-0 lg:h-full">
  44. {/* Mobile: a slim horizontal bar that reopens the panel. */}
  45. <button
  46. type="button"
  47. onClick={() => setCollapsed(false)}
  48. title={t('fileManager.readme.show')}
  49. aria-label={t('fileManager.readme.show')}
  50. className="flex lg:hidden w-full items-center gap-2 px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg hover:bg-bambu-dark/40 transition-colors"
  51. >
  52. <FileText className="w-4 h-4 text-bambu-green flex-shrink-0" />
  53. <span className="text-sm font-medium text-white truncate">{data.filename}</span>
  54. <PanelRightOpen className="w-4 h-4 text-bambu-gray flex-shrink-0 ml-auto" />
  55. </button>
  56. {/* Desktop: a thin vertical strip with a reopen button. */}
  57. <button
  58. type="button"
  59. onClick={() => setCollapsed(false)}
  60. title={t('fileManager.readme.show')}
  61. aria-label={t('fileManager.readme.show')}
  62. className="hidden lg:flex h-full w-10 flex-col items-center gap-2 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg hover:bg-bambu-dark/40 transition-colors"
  63. >
  64. <PanelRightOpen className="w-4 h-4 text-bambu-green flex-shrink-0" />
  65. <span className="text-xs font-medium text-bambu-gray [writing-mode:vertical-rl] rotate-180 select-none">
  66. {t('fileManager.readme.label')}
  67. </span>
  68. </button>
  69. </div>
  70. );
  71. }
  72. return (
  73. <div className="mb-4 lg:mb-0 order-first lg:order-none lg:w-80 xl:w-96 lg:flex-shrink-0 lg:h-full flex flex-col bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg overflow-hidden">
  74. <div className="flex items-center justify-between gap-2 px-3 py-2">
  75. <div className="flex items-center gap-2 min-w-0">
  76. <FileText className="w-4 h-4 text-bambu-green flex-shrink-0" />
  77. <span className="text-sm font-medium text-white truncate" title={data.filename}>
  78. {data.filename}
  79. </span>
  80. {data.truncated && (
  81. <span className="text-xs px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-400 flex-shrink-0">
  82. {t('fileManager.readme.truncated')}
  83. </span>
  84. )}
  85. </div>
  86. <button
  87. type="button"
  88. onClick={() => setCollapsed(true)}
  89. title={t('fileManager.readme.hide')}
  90. aria-label={t('fileManager.readme.hide')}
  91. className="p-1 rounded text-bambu-gray hover:text-white hover:bg-bambu-dark/40 transition-colors flex-shrink-0"
  92. >
  93. <PanelRightClose className="w-4 h-4" />
  94. </button>
  95. </div>
  96. <div className="px-4 py-3 border-t border-bambu-dark-tertiary flex-1 overflow-y-auto max-h-96 lg:max-h-none text-sm text-bambu-gray-light leading-relaxed space-y-2">
  97. <ReactMarkdown
  98. remarkPlugins={[remarkGfm]}
  99. components={{
  100. h1: ({ children }) => <h1 className="text-lg font-semibold text-white mt-2 mb-1">{children}</h1>,
  101. h2: ({ children }) => <h2 className="text-base font-semibold text-white mt-2 mb-1">{children}</h2>,
  102. h3: ({ children }) => <h3 className="text-sm font-semibold text-white mt-2 mb-1">{children}</h3>,
  103. p: ({ children }) => <p className="my-1">{children}</p>,
  104. ul: ({ children }) => <ul className="list-disc list-inside space-y-0.5 ml-2">{children}</ul>,
  105. ol: ({ children }) => <ol className="list-decimal list-inside space-y-0.5 ml-2">{children}</ol>,
  106. li: ({ children }) => <li>{children}</li>,
  107. code: ({ children, ...props }) => {
  108. const inline = !(props as { className?: string }).className;
  109. return inline ? (
  110. <code className="px-1 py-0.5 bg-bambu-dark rounded text-xs font-mono text-bambu-green">{children}</code>
  111. ) : (
  112. <code className="block p-2 bg-bambu-dark rounded text-xs font-mono text-bambu-gray-light overflow-x-auto">{children}</code>
  113. );
  114. },
  115. pre: ({ children }) => <pre className="my-2">{children}</pre>,
  116. blockquote: ({ children }) => (
  117. <blockquote className="border-l-2 border-bambu-dark-tertiary pl-3 text-bambu-gray italic">{children}</blockquote>
  118. ),
  119. a: ({ children, href }) => (
  120. <a href={href} target="_blank" rel="noopener noreferrer" className="text-bambu-green hover:underline">
  121. {children}
  122. </a>
  123. ),
  124. table: ({ children }) => (
  125. <div className="overflow-x-auto">
  126. <table className="min-w-full text-xs border-collapse">{children}</table>
  127. </div>
  128. ),
  129. th: ({ children }) => <th className="border border-bambu-dark-tertiary px-2 py-1 text-left font-semibold text-white">{children}</th>,
  130. td: ({ children }) => <td className="border border-bambu-dark-tertiary px-2 py-1">{children}</td>,
  131. hr: () => <hr className="border-bambu-dark-tertiary my-2" />,
  132. }}
  133. >
  134. {data.content}
  135. </ReactMarkdown>
  136. </div>
  137. </div>
  138. );
  139. }