diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-ai-panel/db-table-ai-panel.component.css b/frontend/src/app/components/dashboard/db-table-view/db-table-ai-panel/db-table-ai-panel.component.css
index 6665fa7c2..6f01a799b 100644
--- a/frontend/src/app/components/dashboard/db-table-view/db-table-ai-panel/db-table-ai-panel.component.css
+++ b/frontend/src/app/components/dashboard/db-table-view/db-table-ai-panel/db-table-ai-panel.component.css
@@ -189,7 +189,21 @@
.ai-message ::ng-deep ol,
.ai-message ::ng-deep ul {
- padding-left: 28px;
+ padding-left: 20px;
+ margin: 8px 0;
+}
+
+.ai-message ::ng-deep li {
+ margin: 4px 0;
+ line-height: 1.5;
+}
+
+.ai-message ::ng-deep ol {
+ list-style-position: outside;
+}
+
+.ai-message ::ng-deep ul {
+ list-style-position: outside;
}
.ai-message ::ng-deep pre {
diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/db-table-widgets.component.ts b/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/db-table-widgets.component.ts
index 46921fd95..1138124d0 100644
--- a/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/db-table-widgets.component.ts
+++ b/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/db-table-widgets.component.ts
@@ -12,6 +12,7 @@ import { FormsModule } from '@angular/forms';
import { ImageEditComponent } from '../../../ui-components/record-edit-fields/image/image.component';
import { Location } from '@angular/common';
import { LongTextEditComponent } from '../../../ui-components/record-edit-fields/long-text/long-text.component';
+import { MarkdownEditComponent } from '../../../ui-components/record-edit-fields/markdown/markdown.component';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatDialogModule } from '@angular/material/dialog';
@@ -53,6 +54,7 @@ import { normalizeTableName } from 'src/app/lib/normalize';
PasswordEditComponent,
ImageEditComponent,
CodeEditComponent,
+ MarkdownEditComponent,
WidgetComponent,
TextEditComponent,
LongTextEditComponent,
@@ -160,6 +162,7 @@ export class DbTableWidgetsComponent implements OnInit {
}
`,
JSON: `// No settings required`,
+ Markdown: `// No settings required`,
Money: `// Configure money widget settings
// example:
{
diff --git a/frontend/src/app/components/ui-components/record-edit-fields/markdown/markdown.component.css b/frontend/src/app/components/ui-components/record-edit-fields/markdown/markdown.component.css
new file mode 100644
index 000000000..d5062f6f0
--- /dev/null
+++ b/frontend/src/app/components/ui-components/record-edit-fields/markdown/markdown.component.css
@@ -0,0 +1,6 @@
+.code-editor-box {
+ min-height: 200px;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+ overflow: hidden;
+}
diff --git a/frontend/src/app/components/ui-components/record-edit-fields/markdown/markdown.component.html b/frontend/src/app/components/ui-components/record-edit-fields/markdown/markdown.component.html
new file mode 100644
index 000000000..f410deb90
--- /dev/null
+++ b/frontend/src/app/components/ui-components/record-edit-fields/markdown/markdown.component.html
@@ -0,0 +1,11 @@
+{{ normalizedLabel }} {{ required ? '*' : '' }}
+
+
+
+
+
diff --git a/frontend/src/app/components/ui-components/record-edit-fields/markdown/markdown.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/markdown/markdown.component.spec.ts
new file mode 100644
index 000000000..ec4fcf891
--- /dev/null
+++ b/frontend/src/app/components/ui-components/record-edit-fields/markdown/markdown.component.spec.ts
@@ -0,0 +1,30 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { MarkdownEditComponent } from './markdown.component';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { provideHttpClient } from '@angular/common/http';
+
+describe('MarkdownEditComponent', () => {
+ let component: MarkdownEditComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [MarkdownEditComponent, BrowserAnimationsModule],
+ providers: [provideHttpClient()]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(MarkdownEditComponent);
+ component = fixture.componentInstance;
+
+ component.widgetStructure = {
+ widget_params: {}
+ } as any;
+
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/frontend/src/app/components/ui-components/record-edit-fields/markdown/markdown.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/markdown/markdown.component.ts
new file mode 100644
index 000000000..89b5eb96a
--- /dev/null
+++ b/frontend/src/app/components/ui-components/record-edit-fields/markdown/markdown.component.ts
@@ -0,0 +1,42 @@
+import { Component, Input } from '@angular/core';
+
+import { BaseEditFieldComponent } from '../base-row-field/base-row-field.component';
+import { CodeEditorModule } from '@ngstack/code-editor';
+import { CommonModule } from '@angular/common';
+import { UiSettingsService } from 'src/app/services/ui-settings.service';
+
+@Component({
+ selector: 'app-edit-markdown',
+ templateUrl: './markdown.component.html',
+ styleUrl: './markdown.component.css',
+ imports: [CommonModule, CodeEditorModule],
+})
+export class MarkdownEditComponent extends BaseEditFieldComponent {
+ @Input() value;
+
+ public mutableCodeModel: Object;
+ public codeEditorOptions = {
+ minimap: { enabled: false },
+ automaticLayout: true,
+ scrollBeyondLastLine: false,
+ wordWrap: 'on',
+ };
+ public codeEditorTheme = 'vs-dark';
+
+ constructor(
+ private _uiSettings: UiSettingsService,
+ ) {
+ super();
+ }
+
+ ngOnInit(): void {
+ super.ngOnInit();
+ this.mutableCodeModel = {
+ language: 'markdown',
+ uri: `${this.label}.md`,
+ value: this.value
+ }
+
+ this.codeEditorTheme = this._uiSettings.editorTheme;
+ }
+}
diff --git a/frontend/src/app/components/ui-components/record-view-fields/markdown/markdown.component.css b/frontend/src/app/components/ui-components/record-view-fields/markdown/markdown.component.css
new file mode 100644
index 000000000..27736edaf
--- /dev/null
+++ b/frontend/src/app/components/ui-components/record-view-fields/markdown/markdown.component.css
@@ -0,0 +1,48 @@
+.markdown-container {
+ padding: 16px;
+ background: #f9f9f9;
+ border-radius: 4px;
+ border: 1px solid #e0e0e0;
+ min-height: 100px;
+}
+
+.markdown-container ::ng-deep h1,
+.markdown-container ::ng-deep h2,
+.markdown-container ::ng-deep h3,
+.markdown-container ::ng-deep h4,
+.markdown-container ::ng-deep h5,
+.markdown-container ::ng-deep h6 {
+ margin-top: 16px;
+ margin-bottom: 8px;
+}
+
+.markdown-container ::ng-deep p {
+ margin-bottom: 8px;
+}
+
+.markdown-container ::ng-deep ul,
+.markdown-container ::ng-deep ol {
+ margin-left: 20px;
+ margin-bottom: 8px;
+}
+
+.markdown-container ::ng-deep code {
+ background: #f5f5f5;
+ padding: 2px 4px;
+ border-radius: 3px;
+ font-family: monospace;
+}
+
+.markdown-container ::ng-deep pre {
+ background: #f5f5f5;
+ padding: 12px;
+ border-radius: 4px;
+ overflow-x: auto;
+}
+
+.markdown-container ::ng-deep blockquote {
+ border-left: 4px solid #ddd;
+ padding-left: 16px;
+ margin-left: 0;
+ color: #666;
+}
diff --git a/frontend/src/app/components/ui-components/record-view-fields/markdown/markdown.component.html b/frontend/src/app/components/ui-components/record-view-fields/markdown/markdown.component.html
new file mode 100644
index 000000000..4270d38fd
--- /dev/null
+++ b/frontend/src/app/components/ui-components/record-view-fields/markdown/markdown.component.html
@@ -0,0 +1,3 @@
+
+
+
diff --git a/frontend/src/app/components/ui-components/record-view-fields/markdown/markdown.component.spec.ts b/frontend/src/app/components/ui-components/record-view-fields/markdown/markdown.component.spec.ts
new file mode 100644
index 000000000..a4262aaca
--- /dev/null
+++ b/frontend/src/app/components/ui-components/record-view-fields/markdown/markdown.component.spec.ts
@@ -0,0 +1,30 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { MarkdownRecordViewComponent } from './markdown.component';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { provideMarkdown } from 'ngx-markdown';
+import { provideHttpClient } from '@angular/common/http';
+
+describe('MarkdownRecordViewComponent', () => {
+ let component: MarkdownRecordViewComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [MarkdownRecordViewComponent, BrowserAnimationsModule],
+ providers: [
+ provideHttpClient(),
+ provideMarkdown()
+ ]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(MarkdownRecordViewComponent);
+ component = fixture.componentInstance;
+ component.value = '# Test Markdown';
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/frontend/src/app/components/ui-components/record-view-fields/markdown/markdown.component.ts b/frontend/src/app/components/ui-components/record-view-fields/markdown/markdown.component.ts
new file mode 100644
index 000000000..9972dae88
--- /dev/null
+++ b/frontend/src/app/components/ui-components/record-view-fields/markdown/markdown.component.ts
@@ -0,0 +1,19 @@
+import { BaseRecordViewFieldComponent } from '../base-record-view-field/base-record-view-field.component';
+import { Component, Injectable, OnInit } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { MarkdownModule } from 'ngx-markdown';
+
+@Injectable()
+@Component({
+ selector: 'app-markdown-record-view',
+ templateUrl: './markdown.component.html',
+ styleUrls: ['../base-record-view-field/base-record-view-field.component.css', './markdown.component.css'],
+ imports: [CommonModule, MarkdownModule]
+})
+export class MarkdownRecordViewComponent extends BaseRecordViewFieldComponent implements OnInit {
+ public renderedMarkdown: string = '';
+
+ ngOnInit(): void {
+ this.renderedMarkdown = this.value || '';
+ }
+}
diff --git a/frontend/src/app/components/ui-components/table-display-fields/markdown/markdown.component.css b/frontend/src/app/components/ui-components/table-display-fields/markdown/markdown.component.css
new file mode 100644
index 000000000..6c6f41777
--- /dev/null
+++ b/frontend/src/app/components/ui-components/table-display-fields/markdown/markdown.component.css
@@ -0,0 +1,40 @@
+.markdown-preview {
+ display: inline-block;
+ max-width: 300px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.markdown-preview ::ng-deep p {
+ margin: 0;
+ display: inline;
+}
+
+.markdown-preview ::ng-deep h1,
+.markdown-preview ::ng-deep h2,
+.markdown-preview ::ng-deep h3,
+.markdown-preview ::ng-deep h4,
+.markdown-preview ::ng-deep h5,
+.markdown-preview ::ng-deep h6 {
+ margin: 0;
+ display: inline;
+ font-size: inherit;
+ font-weight: bold;
+}
+
+.markdown-preview ::ng-deep ul,
+.markdown-preview ::ng-deep ol {
+ display: inline;
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.markdown-preview ::ng-deep code {
+ background: #f5f5f5;
+ padding: 1px 3px;
+ border-radius: 2px;
+ font-family: monospace;
+ font-size: 0.9em;
+}
diff --git a/frontend/src/app/components/ui-components/table-display-fields/markdown/markdown.component.html b/frontend/src/app/components/ui-components/table-display-fields/markdown/markdown.component.html
new file mode 100644
index 000000000..aaa8bbe84
--- /dev/null
+++ b/frontend/src/app/components/ui-components/table-display-fields/markdown/markdown.component.html
@@ -0,0 +1,13 @@
+
+
+ {{ displayTitle }}
+
+
+
diff --git a/frontend/src/app/components/ui-components/table-display-fields/markdown/markdown.component.spec.ts b/frontend/src/app/components/ui-components/table-display-fields/markdown/markdown.component.spec.ts
new file mode 100644
index 000000000..e2a18966d
--- /dev/null
+++ b/frontend/src/app/components/ui-components/table-display-fields/markdown/markdown.component.spec.ts
@@ -0,0 +1,99 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { MarkdownDisplayComponent } from './markdown.component';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { provideMarkdown } from 'ngx-markdown';
+import { provideHttpClient } from '@angular/common/http';
+
+describe('MarkdownDisplayComponent', () => {
+ let component: MarkdownDisplayComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [MarkdownDisplayComponent, BrowserAnimationsModule],
+ providers: [
+ provideHttpClient(),
+ provideMarkdown()
+ ]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(MarkdownDisplayComponent);
+ component = fixture.componentInstance;
+ component.value = '# Test Markdown';
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('Title extraction', () => {
+ it('should extract H1 heading as title', () => {
+ component.value = '# Main Title\n\nSome paragraph text here.';
+ component.ngOnInit();
+ expect(component.displayTitle).toBe('Main Title');
+ });
+
+ it('should extract H2 heading as title', () => {
+ component.value = '## Secondary Title\n\nContent follows.';
+ component.ngOnInit();
+ expect(component.displayTitle).toBe('Secondary Title');
+ });
+
+ it('should prioritize heading over paragraph', () => {
+ component.value = 'Some intro text\n\n# Actual Title\n\nParagraph content.';
+ component.ngOnInit();
+ expect(component.displayTitle).toBe('Actual Title');
+ });
+
+ it('should use first paragraph if no heading exists', () => {
+ component.value = 'This is the first paragraph.\n\nThis is the second paragraph.';
+ component.ngOnInit();
+ expect(component.displayTitle).toBe('This is the first paragraph.');
+ });
+
+ it('should truncate long titles at 100 characters', () => {
+ const longTitle = 'a'.repeat(150);
+ component.value = `# ${longTitle}`;
+ component.ngOnInit();
+ expect(component.displayTitle).toBe('a'.repeat(100) + '...');
+ });
+
+ it('should handle markdown with bold and italic formatting', () => {
+ component.value = '# **Bold** and *italic* title';
+ component.ngOnInit();
+ expect(component.displayTitle).toBe('**Bold** and *italic* title');
+ });
+
+ it('should handle empty markdown', () => {
+ component.value = '';
+ component.ngOnInit();
+ expect(component.displayTitle).toBe('—');
+ });
+
+ it('should handle null/undefined markdown', () => {
+ component.value = null as any;
+ component.ngOnInit();
+ expect(component.displayTitle).toBe('—');
+ });
+
+ it('should handle markdown with only whitespace', () => {
+ component.value = ' \n\n ';
+ component.ngOnInit();
+ expect(component.displayTitle).toBe('—');
+ });
+
+ it('should extract title from markdown with code blocks', () => {
+ component.value = '# API Documentation\n\n```javascript\nconst x = 5;\n```';
+ component.ngOnInit();
+ expect(component.displayTitle).toBe('API Documentation');
+ });
+
+ it('should extract title from markdown with lists', () => {
+ component.value = '# Shopping List\n\n- Item 1\n- Item 2';
+ component.ngOnInit();
+ expect(component.displayTitle).toBe('Shopping List');
+ });
+ });
+});
diff --git a/frontend/src/app/components/ui-components/table-display-fields/markdown/markdown.component.ts b/frontend/src/app/components/ui-components/table-display-fields/markdown/markdown.component.ts
new file mode 100644
index 000000000..0998b1786
--- /dev/null
+++ b/frontend/src/app/components/ui-components/table-display-fields/markdown/markdown.component.ts
@@ -0,0 +1,80 @@
+import { BaseTableDisplayFieldComponent } from '../base-table-display-field/base-table-display-field.component';
+import { ClipboardModule } from '@angular/cdk/clipboard';
+import { Component, OnInit } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { MarkdownModule } from 'ngx-markdown';
+import { MatButtonModule } from '@angular/material/button';
+import { MatIconModule } from '@angular/material/icon';
+import { MatTooltipModule } from '@angular/material/tooltip';
+import { marked } from 'marked';
+
+@Component({
+ selector: 'app-markdown-display',
+ templateUrl: './markdown.component.html',
+ styleUrls: ['../base-table-display-field/base-table-display-field.component.css', './markdown.component.css'],
+ imports: [CommonModule, MarkdownModule, ClipboardModule, MatIconModule, MatButtonModule, MatTooltipModule]
+})
+export class MarkdownDisplayComponent extends BaseTableDisplayFieldComponent implements OnInit {
+ public renderedMarkdown: string = '';
+ public displayTitle: string = '';
+
+ ngOnInit(): void {
+ this.renderedMarkdown = this.value || '';
+ this.displayTitle = this._extractTitle(this.value);
+ }
+
+ /**
+ * Extracts a title from markdown content using marked lexer
+ * Prioritizes headings, then falls back to first paragraph
+ * @param markdown The markdown content
+ * @returns Extracted and cleaned title string
+ */
+ private _extractTitle(markdown: string): string {
+ if (!markdown || markdown.trim() === '') {
+ return '—';
+ }
+
+ try {
+ const tokens = marked.lexer(markdown);
+
+ // First pass: look for headings (h1-h6)
+ for (const token of tokens) {
+ if (token.type === 'heading') {
+ return this._truncateText(token.text, 100);
+ }
+ }
+
+ // Second pass: look for first paragraph or text
+ for (const token of tokens) {
+ if (token.type === 'paragraph') {
+ return this._truncateText(token.text, 100);
+ }
+ if (token.type === 'text' && token.text.trim().length > 0) {
+ return this._truncateText(token.text, 100);
+ }
+ }
+
+ // If no heading or paragraph found, return first non-empty line
+ const firstLine = markdown.trim().split('\n')[0];
+ return this._truncateText(firstLine, 100);
+ } catch (error) {
+ // Fallback if parsing fails
+ const firstLine = markdown.trim().split('\n')[0];
+ return this._truncateText(firstLine, 100);
+ }
+ }
+
+ /**
+ * Truncates text to specified length and adds ellipsis if needed
+ * @param text Text to truncate
+ * @param maxLength Maximum length before truncation
+ * @returns Truncated text with ellipsis if applicable
+ */
+ private _truncateText(text: string, maxLength: number): string {
+ const cleaned = text.trim();
+ if (cleaned.length > maxLength) {
+ return cleaned.substring(0, maxLength) + '...';
+ }
+ return cleaned || '—';
+ }
+}
diff --git a/frontend/src/app/consts/record-edit-types.ts b/frontend/src/app/consts/record-edit-types.ts
index 7f01a9548..ba6fabe41 100644
--- a/frontend/src/app/consts/record-edit-types.ts
+++ b/frontend/src/app/consts/record-edit-types.ts
@@ -11,6 +11,7 @@ import { IdEditComponent } from '../components/ui-components/record-edit-fields/
import { ImageEditComponent } from '../components/ui-components/record-edit-fields/image/image.component';
import { JsonEditorEditComponent } from '../components/ui-components/record-edit-fields/json-editor/json-editor.component';
import { LongTextEditComponent } from 'src/app/components/ui-components/record-edit-fields/long-text/long-text.component'
+import { MarkdownEditComponent } from '../components/ui-components/record-edit-fields/markdown/markdown.component';
import { MoneyEditComponent } from '../components/ui-components/record-edit-fields/money/money.component';
import { NumberEditComponent } from 'src/app/components/ui-components/record-edit-fields/number/number.component';
import { PasswordEditComponent } from '../components/ui-components/record-edit-fields/password/password.component';
@@ -44,6 +45,7 @@ export const UIwidgets = {
Foreign_key: ForeignKeyEditComponent,
Image: ImageEditComponent,
JSON: JsonEditorEditComponent,
+ Markdown: MarkdownEditComponent,
Money: MoneyEditComponent,
Number: NumberEditComponent,
Password: PasswordEditComponent,
diff --git a/frontend/src/app/consts/record-view-types.ts b/frontend/src/app/consts/record-view-types.ts
index eb5d416a8..13553a5b9 100644
--- a/frontend/src/app/consts/record-view-types.ts
+++ b/frontend/src/app/consts/record-view-types.ts
@@ -10,6 +10,7 @@ import { IdRecordViewComponent } from '../components/ui-components/record-view-f
import { ImageRecordViewComponent } from '../components/ui-components/record-view-fields/image/image.component';
import { JsonEditorRecordViewComponent } from '../components/ui-components/record-view-fields/json-editor/json-editor.component';
import { LongTextRecordViewComponent } from 'src/app/components/ui-components/record-view-fields/long-text/long-text.component';
+import { MarkdownRecordViewComponent } from '../components/ui-components/record-view-fields/markdown/markdown.component';
import { MoneyRecordViewComponent } from '../components/ui-components/record-view-fields/money/money.component';
import { NumberRecordViewComponent } from '../components/ui-components/record-view-fields/number/number.component';
import { PasswordRecordViewComponent } from '../components/ui-components/record-view-fields/password/password.component';
@@ -32,6 +33,7 @@ export const UIwidgets = {
Time: TimeRecordViewComponent,
DateTime: DateTimeRecordViewComponent,
JSON: JsonEditorRecordViewComponent,
+ Markdown: MarkdownRecordViewComponent,
Textarea: LongTextRecordViewComponent,
String: TextRecordViewComponent,
Readonly: StaticTextRecordViewComponent,
diff --git a/frontend/src/app/consts/table-display-types.ts b/frontend/src/app/consts/table-display-types.ts
index d0eda963a..e640d660a 100644
--- a/frontend/src/app/consts/table-display-types.ts
+++ b/frontend/src/app/consts/table-display-types.ts
@@ -10,6 +10,7 @@ import { IdDisplayComponent } from '../components/ui-components/table-display-fi
import { ImageDisplayComponent } from '../components/ui-components/table-display-fields/image/image.component';
import { JsonEditorDisplayComponent } from '../components/ui-components/table-display-fields/json-editor/json-editor.component';
import { LongTextDisplayComponent } from 'src/app/components/ui-components/table-display-fields/long-text/long-text.component';
+import { MarkdownDisplayComponent } from '../components/ui-components/table-display-fields/markdown/markdown.component';
import { MoneyDisplayComponent } from '../components/ui-components/table-display-fields/money/money.component';
import { NumberDisplayComponent } from '../components/ui-components/table-display-fields/number/number.component';
import { PasswordDisplayComponent } from '../components/ui-components/table-display-fields/password/password.component';
@@ -37,6 +38,7 @@ export const UIwidgets = {
Foreign_key: ForeignKeyDisplayComponent,
Image: ImageDisplayComponent,
JSON: JsonEditorDisplayComponent,
+ Markdown: MarkdownDisplayComponent,
Money: MoneyDisplayComponent,
Number: NumberDisplayComponent,
Password: PasswordDisplayComponent,