조민수 조민수 05-28
editor 추가 및 권한관리 작업 완료 [컨텐츠 관리], [권한관리]
[컨텐츠 관리], [권한관리]
@c4884bf80208d032f1bdac5771aacdd2629c4a1f
package-lock.json
--- package-lock.json
+++ package-lock.json
@@ -9,7 +9,19 @@
       "version": "0.0.0",
       "dependencies": {
         "@tanstack/react-query": "^5.99.0",
+        "@tiptap/extension-image": "^3.23.5",
+        "@tiptap/extension-link": "^3.23.5",
+        "@tiptap/extension-placeholder": "^3.23.5",
+        "@tiptap/extension-table": "^3.23.5",
+        "@tiptap/extension-table-cell": "^3.23.5",
+        "@tiptap/extension-table-header": "^3.23.5",
+        "@tiptap/extension-table-row": "^3.23.5",
+        "@tiptap/extension-text-align": "^3.23.5",
+        "@tiptap/extension-underline": "^3.23.5",
+        "@tiptap/react": "^3.23.5",
+        "@tiptap/starter-kit": "^3.23.5",
         "axios": "^1.16.0",
+        "dompurify": "^3.4.5",
         "react": "^19.2.4",
         "react-dom": "^19.2.4",
         "react-router-dom": "^7.14.1",
@@ -461,6 +473,34 @@
         "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
       }
     },
+    "node_modules/@floating-ui/core": {
+      "version": "1.7.5",
+      "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
+      "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "@floating-ui/utils": "^0.2.11"
+      }
+    },
+    "node_modules/@floating-ui/dom": {
+      "version": "1.7.6",
+      "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
+      "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "@floating-ui/core": "^1.7.5",
+        "@floating-ui/utils": "^0.2.11"
+      }
+    },
+    "node_modules/@floating-ui/utils": {
+      "version": "0.2.11",
+      "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
+      "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
+      "license": "MIT",
+      "optional": true
+    },
     "node_modules/@humanfs/core": {
       "version": "0.19.1",
       "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -900,6 +940,526 @@
         "react": "^18 || ^19"
       }
     },
+    "node_modules/@tiptap/core": {
+      "version": "3.23.5",
+      "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.23.5.tgz",
+      "integrity": "sha512-657Xqcgf1IYWLkAmRDJKNSGdoS1AHJEgK6zHWHFJERQGIqHnwC7Fz7nvWs/NQhQVBkclQd0ERRdTCZ3XwRc1+g==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/pm": "3.23.5"
+      }
+    },
+    "node_modules/@tiptap/extension-blockquote": {
+      "version": "3.23.5",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.23.5.tgz",
+      "integrity": "sha512-PBQRoGfSWfIY7HmGbS5PTHEBQl5nKbild5J5phPLFF+O3aOBQ0d49AC9cxbaou/6FRCtq6g4Uqse9rRTKJRM0w==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "3.23.5"
+      }
+    },
+    "node_modules/@tiptap/extension-bold": {
+      "version": "3.23.5",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.23.5.tgz",
+      "integrity": "sha512-DZsDCCf53fA9HmsFzfUHl5jLOwDYf+XzfP+QJjJ4cK23SsxDirameTjgnwi4l1EgEPLWunMZQjU+wHmh7vvX6Q==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "3.23.5"
+      }
+    },
+    "node_modules/@tiptap/extension-bubble-menu": {
+      "version": "3.23.5",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.23.5.tgz",
+      "integrity": "sha512-otcGwyVO6OfxdDPnbooZxYGrb+6q5WYmS+g2V+XGGNRn5oJgyY5pW0dqELIUJ66dosIIXXPyw2XqBDpMMY2kyQ==",
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "@floating-ui/dom": "^1.0.0"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "3.23.5",
+        "@tiptap/pm": "3.23.5"
+      }
+    },
+    "node_modules/@tiptap/extension-bullet-list": {
+      "version": "3.23.5",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.23.5.tgz",
+      "integrity": "sha512-o0bzZbFvOPhPX6+RAhIFPKMIN3jIenY6Ib3FJ6ZqxTdVcjuV2mIXUmJU0uV2BwKtz73GmKSRKRKia6KJ0ml8qA==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/extension-list": "3.23.5"
+      }
+    },
+    "node_modules/@tiptap/extension-code": {
+      "version": "3.23.5",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.23.5.tgz",
+      "integrity": "sha512-NOJUD2Z0hrtBWnovXiiH1XtOjEQePOfIG3bNJgXSs1bWxPVhqp6KjVd8mUJNra974hxbml3tC97sL9QqjpAWFg==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "3.23.5"
+      }
+    },
+    "node_modules/@tiptap/extension-code-block": {
+      "version": "3.23.5",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.23.5.tgz",
+      "integrity": "sha512-P2XH8WPM4UahavcWoQgAwNAKQCbF/JWi6ZqgsQmVBfAqQ3mf8gMxB7HnciMq1DlyI9EfjXoJH11yUqldF/6AaQ==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "3.23.5",
+        "@tiptap/pm": "3.23.5"
+      }
+    },
+    "node_modules/@tiptap/extension-document": {
+      "version": "3.23.5",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.23.5.tgz",
+      "integrity": "sha512-Y7uPjEM1xIK4Spcdk/kp/vZ/Az3cEaglTCk6uHrWvNFVglEoGehNb6IQbQFZW0wjE19YoMIiLBLtG6V9dqrpBw==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "3.23.5"
+      }
+    },
+    "node_modules/@tiptap/extension-dropcursor": {
+      "version": "3.23.5",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.23.5.tgz",
+      "integrity": "sha512-l72R798Q69D6f89Vp9xreoRnPcpK0LHPKLZIc6pvqBC2iOjx5wLKtW0uP1uqVWdQtvF5AUYBRNIGAQ5Gel9XEg==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/extensions": "3.23.5"
+      }
+    },
+    "node_modules/@tiptap/extension-floating-menu": {
+      "version": "3.23.5",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.23.5.tgz",
+      "integrity": "sha512-kP0bZKH/lxNogfvoIy/YJZ5gkty0OwqFVtQUwoc85vXYUfvy5Jh1VdO053tCE1iDzmvOITUpcb+MdWryP8dBxA==",
+      "license": "MIT",
+      "optional": true,
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@floating-ui/dom": "^1.0.0",
+        "@tiptap/core": "3.23.5",
+        "@tiptap/pm": "3.23.5"
+      }
+    },
+    "node_modules/@tiptap/extension-gapcursor": {
+      "version": "3.23.5",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.23.5.tgz",
+      "integrity": "sha512-x9XlYG26TowX0Ly1w0ZV2D8qliyQy9fTmMY4suI6B/6o6m/sXHGTAJMmJqwP66sZKF6cMLU3HECumhtyQxPT2g==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/extensions": "3.23.5"
+      }
+    },
+    "node_modules/@tiptap/extension-hard-break": {
+      "version": "3.23.5",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.23.5.tgz",
+      "integrity": "sha512-j/BDBMOA1mA+RhCx622SRPBhpp2XWNFYz9asbg8T3yk8v9WI3Vjo6IDlfTp6fwsR2LGE7Pek3R0xDAjW6yVG3g==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "3.23.5"
+      }
+    },
+    "node_modules/@tiptap/extension-heading": {
+      "version": "3.23.5",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.23.5.tgz",
+      "integrity": "sha512-tFI+iYk34geacVOGqYgyoC8siQjdGn605XaYSZcGRFF8NY+HrGlLkQi2QRRCeLaUhxoctONmWc8USn3H5U7wLQ==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "3.23.5"
+      }
+    },
+    "node_modules/@tiptap/extension-horizontal-rule": {
+      "version": "3.23.5",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.23.5.tgz",
+      "integrity": "sha512-9XkRYc4XE0stERZB3y8bsJd32Jw9UZfMwZXo1GLNYRHFr7dmhSGUj0IzgofqOVmLDcOMW6XcCk54TBYw6BCrWA==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "3.23.5",
+        "@tiptap/pm": "3.23.5"
+      }
+    },
+    "node_modules/@tiptap/extension-image": {
+      "version": "3.23.5",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.23.5.tgz",
+      "integrity": "sha512-v6u9zbJSKLjml6DDn1/1WOOIzVxz3K5Idl1EgUl+IpJH7kR1HLRJ3TaSgF7z2RRQmqyHlmtdCzdaKoe0jCIyqQ==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "3.23.5"
+      }
+    },
+    "node_modules/@tiptap/extension-italic": {
+      "version": "3.23.5",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.23.5.tgz",
+      "integrity": "sha512-XjRSPr6j4mz+8O5j5KNfxVb+1fGNt0wr+js6MLxxGdU7M+PoDPdVY6fARbmBazv4ERlZ5PNS9m35Vo5xDjDfrg==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "3.23.5"
+      }
+    },
+    "node_modules/@tiptap/extension-link": {
+      "version": "3.23.5",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.23.5.tgz",
+      "integrity": "sha512-FEI58NAPnauBbs4nw1dkgRyEhcWnure0vIlStfQoQGXxj3xSRvxKH2lOkz54fGzuzRJAoudyLU65HW6D7kc+8Q==",
+      "license": "MIT",
+      "dependencies": {
+        "linkifyjs": "^4.3.3"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "3.23.5",
+        "@tiptap/pm": "3.23.5"
+      }
+    },
+    "node_modules/@tiptap/extension-list": {
+      "version": "3.23.5",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.23.5.tgz",
+      "integrity": "sha512-nzZXpVwnyKwTj4TVyPyu1bCUFjJCsaXnhAthmvJDnX3RBtemNG9Ka07xGR2NIspzumSbQSMFtDxjmxv3W5dEtg==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "3.23.5",
+        "@tiptap/pm": "3.23.5"
+      }
+    },
+    "node_modules/@tiptap/extension-list-item": {
+      "version": "3.23.5",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.23.5.tgz",
+      "integrity": "sha512-l7Hb4rfNIkO6JrNJYkdXap6QYXCz4XeeFmI1bfQgEiwPGs+RAn/+0cOdg7q+6MmtZFac5uSXV0PftPk6A0GsEA==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/extension-list": "3.23.5"
+      }
+    },
+    "node_modules/@tiptap/extension-list-keymap": {
+      "version": "3.23.5",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.23.5.tgz",
+      "integrity": "sha512-Hz8jRA51VSiHezEkwqwaMYbTEYcR/5Aq3UgCgDlNPlE6k1OZrvRtV/4s3AOO0RRgzyVLKv7yv7KuOJN/OLGErw==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/extension-list": "3.23.5"
+      }
+    },
+    "node_modules/@tiptap/extension-ordered-list": {
+      "version": "3.23.5",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.23.5.tgz",
+      "integrity": "sha512-qQeU71ij0cAAD9bbGqot5T5bpR3dysgQ+W67quRs6VDyusU89EYaJHKn/qWU6a1XOEQ4sL+5GNw52FYQVHUxbA==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/extension-list": "3.23.5"
+      }
+    },
+    "node_modules/@tiptap/extension-paragraph": {
+      "version": "3.23.5",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.23.5.tgz",
+      "integrity": "sha512-LtgMcR1rvWnZDtphFJ/LBltlC0+6HGA07k7vhy+U7P/zIg/V3Fb4RD6YDuAo0cPfBsLm8p1WYJV92WpAsGgtlg==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "3.23.5"
+      }
+    },
+    "node_modules/@tiptap/extension-placeholder": {
+      "version": "3.23.5",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.23.5.tgz",
+      "integrity": "sha512-B2snUujc6fb/16p8jSQCN4+mto7RlHKLm8quBTUWXksY8D82u/cxjUdmRQ7ueq7vsbRsA+WoJTrKEjJ8RQOpjw==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/extensions": "3.23.5"
+      }
+    },
+    "node_modules/@tiptap/extension-strike": {
+      "version": "3.23.5",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.23.5.tgz",
+      "integrity": "sha512-PMB9lpQGOJGuRTIS9rBw8UZtHQwmsiJbWKjcBr5z20MluaJQ3ZCHFhDYG6ncIDRz+0ny4ZvoJ7cKGpI+NTvXMA==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "3.23.5"
+      }
+    },
+    "node_modules/@tiptap/extension-table": {
+      "version": "3.23.5",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-3.23.5.tgz",
+      "integrity": "sha512-3uTaC+LsilQHaMGTW6vK4fXHsTYL/TPGM0mxoBz8UvMl+G/uzL149RcMC0d0qKvYPxInFQ2rFzxPTpnY3Rg3UA==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "3.23.5",
+        "@tiptap/pm": "3.23.5"
+      }
+    },
+    "node_modules/@tiptap/extension-table-cell": {
+      "version": "3.23.5",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-3.23.5.tgz",
+      "integrity": "sha512-P8agH3q1EDEGTcs9+BXqIv/2weUkJfy63SIQetU28egtNRJt1L+rhnrkwzd95f01TNN/TRSyW01OAZ3tj8tHKA==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/extension-table": "3.23.5"
+      }
+    },
+    "node_modules/@tiptap/extension-table-header": {
+      "version": "3.23.5",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-3.23.5.tgz",
+      "integrity": "sha512-CeEiMwEg4L38e8c4qJovasKaaSkcAUrMNNfvF/qE8WogSYfke2/OAy4cZ47bhzd5oauSUjiIUnmsY11Q0jdX/Q==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/extension-table": "3.23.5"
+      }
+    },
+    "node_modules/@tiptap/extension-table-row": {
+      "version": "3.23.5",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-3.23.5.tgz",
+      "integrity": "sha512-tbpl+kSHeuPIqwgPw3SuQXS/rzdzRYigxXY1fCeWea20wONMQhLHyaL710vFcugIW6d9aSgmvdiwcGS/MneL0A==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/extension-table": "3.23.5"
+      }
+    },
+    "node_modules/@tiptap/extension-text": {
+      "version": "3.23.5",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.23.5.tgz",
+      "integrity": "sha512-GLa+AaA2NC5XYRZad/Qq/oH5Pa95s+uA17J7+RCkF8j1RNREUBkYQ5CD5MT8kT+D3DHgU8MRyYdTd28I46HBDQ==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "3.23.5"
+      }
+    },
+    "node_modules/@tiptap/extension-text-align": {
+      "version": "3.23.5",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-3.23.5.tgz",
+      "integrity": "sha512-eOeXrbpPWc6gfXli2aXYg9t61HhkvEkdxQgpEpZPFhrT4pPQcIqTlihswByC+cPb8B5ynrc/iamiY9cRSU1qvw==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "3.23.5"
+      }
+    },
+    "node_modules/@tiptap/extension-underline": {
+      "version": "3.23.5",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.23.5.tgz",
+      "integrity": "sha512-fyxthzE6CNCi9a9OVAwXs1sSyJ7jlrzT3aP2KhYLQCsJABHaPJgJA7k52/CRuKqCW3WbxU1ULH9LGuGtBbhEyw==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "3.23.5"
+      }
+    },
+    "node_modules/@tiptap/extensions": {
+      "version": "3.23.5",
+      "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.23.5.tgz",
+      "integrity": "sha512-ROcdNPV+buzldEFKvD3o29P7H7zpAf2lnLfndO2LHSToWyHw4hlzVPCeAU8uAvhl/jyfeUoFLrBwxphMX/KG6A==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "3.23.5",
+        "@tiptap/pm": "3.23.5"
+      }
+    },
+    "node_modules/@tiptap/pm": {
+      "version": "3.23.5",
+      "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.23.5.tgz",
+      "integrity": "sha512-9tgLdpTvNN0/fLP4RcNzbyQ0qjg9J2ahaFbQzgV5uvd+QMy8Xkg2IqKKnOoJJUAV3FDjGq3Yx0WrV2BGro9pfw==",
+      "license": "MIT",
+      "dependencies": {
+        "prosemirror-changeset": "^2.3.0",
+        "prosemirror-commands": "^1.6.2",
+        "prosemirror-dropcursor": "^1.8.1",
+        "prosemirror-gapcursor": "^1.3.2",
+        "prosemirror-history": "^1.4.1",
+        "prosemirror-keymap": "^1.2.2",
+        "prosemirror-model": "^1.24.1",
+        "prosemirror-schema-list": "^1.5.0",
+        "prosemirror-state": "^1.4.3",
+        "prosemirror-tables": "^1.6.4",
+        "prosemirror-transform": "^1.10.2",
+        "prosemirror-view": "^1.38.1"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      }
+    },
+    "node_modules/@tiptap/react": {
+      "version": "3.23.5",
+      "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.23.5.tgz",
+      "integrity": "sha512-aEdKfJxoa6tCEV4FrnBqMQoUPwGcTWLaDzmP4fL1gR7E40rYDTiYNKoF1Ob+UimUpguAP6Emv1WlJa5oyI8FSw==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/use-sync-external-store": "^0.0.6",
+        "fast-equals": "^5.3.3",
+        "use-sync-external-store": "^1.4.0"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "optionalDependencies": {
+        "@tiptap/extension-bubble-menu": "^3.23.5",
+        "@tiptap/extension-floating-menu": "^3.23.5"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "3.23.5",
+        "@tiptap/pm": "3.23.5",
+        "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+        "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
+        "react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+        "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
+      }
+    },
+    "node_modules/@tiptap/starter-kit": {
+      "version": "3.23.5",
+      "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.23.5.tgz",
+      "integrity": "sha512-ac0edQ1a1nYkNAzOgdqIBKGdrOlNQpPP9wGAG3Q9EgTq4+C4/EftJZZJmUn3KzaSOUv4cLEDo0z0jurJvZPkaw==",
+      "license": "MIT",
+      "dependencies": {
+        "@tiptap/core": "^3.23.5",
+        "@tiptap/extension-blockquote": "^3.23.5",
+        "@tiptap/extension-bold": "^3.23.5",
+        "@tiptap/extension-bullet-list": "^3.23.5",
+        "@tiptap/extension-code": "^3.23.5",
+        "@tiptap/extension-code-block": "^3.23.5",
+        "@tiptap/extension-document": "^3.23.5",
+        "@tiptap/extension-dropcursor": "^3.23.5",
+        "@tiptap/extension-gapcursor": "^3.23.5",
+        "@tiptap/extension-hard-break": "^3.23.5",
+        "@tiptap/extension-heading": "^3.23.5",
+        "@tiptap/extension-horizontal-rule": "^3.23.5",
+        "@tiptap/extension-italic": "^3.23.5",
+        "@tiptap/extension-link": "^3.23.5",
+        "@tiptap/extension-list": "^3.23.5",
+        "@tiptap/extension-list-item": "^3.23.5",
+        "@tiptap/extension-list-keymap": "^3.23.5",
+        "@tiptap/extension-ordered-list": "^3.23.5",
+        "@tiptap/extension-paragraph": "^3.23.5",
+        "@tiptap/extension-strike": "^3.23.5",
+        "@tiptap/extension-text": "^3.23.5",
+        "@tiptap/extension-underline": "^3.23.5",
+        "@tiptap/extensions": "^3.23.5",
+        "@tiptap/pm": "^3.23.5"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      }
+    },
     "node_modules/@tybys/wasm-util": {
       "version": "0.10.1",
       "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@@ -939,7 +1499,6 @@
       "version": "19.2.14",
       "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
       "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "csstype": "^3.2.2"
@@ -949,11 +1508,23 @@
       "version": "19.2.3",
       "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
       "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
-      "dev": true,
       "license": "MIT",
       "peerDependencies": {
         "@types/react": "^19.2.0"
       }
+    },
+    "node_modules/@types/trusted-types": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+      "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+      "license": "MIT",
+      "optional": true
+    },
+    "node_modules/@types/use-sync-external-store": {
+      "version": "0.0.6",
+      "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
+      "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
+      "license": "MIT"
     },
     "node_modules/@typescript-eslint/eslint-plugin": {
       "version": "8.58.2",
@@ -1569,7 +2140,6 @@
       "version": "3.2.3",
       "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
       "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
-      "dev": true,
       "license": "MIT"
     },
     "node_modules/debug": {
@@ -1614,6 +2184,15 @@
       "license": "Apache-2.0",
       "engines": {
         "node": ">=8"
+      }
+    },
+    "node_modules/dompurify": {
+      "version": "3.4.5",
+      "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.5.tgz",
+      "integrity": "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==",
+      "license": "(MPL-2.0 OR Apache-2.0)",
+      "optionalDependencies": {
+        "@types/trusted-types": "^2.0.7"
       }
     },
     "node_modules/dunder-proto": {
@@ -1895,6 +2474,15 @@
       "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
       "dev": true,
       "license": "MIT"
+    },
+    "node_modules/fast-equals": {
+      "version": "5.4.0",
+      "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
+      "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.0.0"
+      }
     },
     "node_modules/fast-json-stable-stringify": {
       "version": "2.1.0",
@@ -2621,6 +3209,12 @@
         "url": "https://opencollective.com/parcel"
       }
     },
+    "node_modules/linkifyjs": {
+      "version": "4.3.3",
+      "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
+      "integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==",
+      "license": "MIT"
+    },
     "node_modules/locate-path": {
       "version": "6.0.0",
       "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -2755,6 +3349,12 @@
         "node": ">= 0.8.0"
       }
     },
+    "node_modules/orderedmap": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
+      "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
+      "license": "MIT"
+    },
     "node_modules/p-limit": {
       "version": "3.1.0",
       "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@@ -2877,6 +3477,135 @@
       "license": "MIT",
       "engines": {
         "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/prosemirror-changeset": {
+      "version": "2.4.1",
+      "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz",
+      "integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==",
+      "license": "MIT",
+      "dependencies": {
+        "prosemirror-transform": "^1.0.0"
+      }
+    },
+    "node_modules/prosemirror-commands": {
+      "version": "1.7.1",
+      "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
+      "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
+      "license": "MIT",
+      "dependencies": {
+        "prosemirror-model": "^1.0.0",
+        "prosemirror-state": "^1.0.0",
+        "prosemirror-transform": "^1.10.2"
+      }
+    },
+    "node_modules/prosemirror-dropcursor": {
+      "version": "1.8.2",
+      "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
+      "integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
+      "license": "MIT",
+      "dependencies": {
+        "prosemirror-state": "^1.0.0",
+        "prosemirror-transform": "^1.1.0",
+        "prosemirror-view": "^1.1.0"
+      }
+    },
+    "node_modules/prosemirror-gapcursor": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
+      "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
+      "license": "MIT",
+      "dependencies": {
+        "prosemirror-keymap": "^1.0.0",
+        "prosemirror-model": "^1.0.0",
+        "prosemirror-state": "^1.0.0",
+        "prosemirror-view": "^1.0.0"
+      }
+    },
+    "node_modules/prosemirror-history": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
+      "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
+      "license": "MIT",
+      "dependencies": {
+        "prosemirror-state": "^1.2.2",
+        "prosemirror-transform": "^1.0.0",
+        "prosemirror-view": "^1.31.0",
+        "rope-sequence": "^1.3.0"
+      }
+    },
+    "node_modules/prosemirror-keymap": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
+      "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
+      "license": "MIT",
+      "dependencies": {
+        "prosemirror-state": "^1.0.0",
+        "w3c-keyname": "^2.2.0"
+      }
+    },
+    "node_modules/prosemirror-model": {
+      "version": "1.25.7",
+      "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.7.tgz",
+      "integrity": "sha512-A79aN8QEFUwI6cax8Yq4Rpcx1TJZ3Kagn+ii7qLo4/V8H3mMiHrhFyhTyHHvpSnOgMPpWiDGSwM3etwrxE50ug==",
+      "license": "MIT",
+      "dependencies": {
+        "orderedmap": "^2.0.0"
+      }
+    },
+    "node_modules/prosemirror-schema-list": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
+      "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
+      "license": "MIT",
+      "dependencies": {
+        "prosemirror-model": "^1.0.0",
+        "prosemirror-state": "^1.0.0",
+        "prosemirror-transform": "^1.7.3"
+      }
+    },
+    "node_modules/prosemirror-state": {
+      "version": "1.4.4",
+      "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
+      "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
+      "license": "MIT",
+      "dependencies": {
+        "prosemirror-model": "^1.0.0",
+        "prosemirror-transform": "^1.0.0",
+        "prosemirror-view": "^1.27.0"
+      }
+    },
+    "node_modules/prosemirror-tables": {
+      "version": "1.8.5",
+      "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
+      "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
+      "license": "MIT",
+      "dependencies": {
+        "prosemirror-keymap": "^1.2.3",
+        "prosemirror-model": "^1.25.4",
+        "prosemirror-state": "^1.4.4",
+        "prosemirror-transform": "^1.10.5",
+        "prosemirror-view": "^1.41.4"
+      }
+    },
+    "node_modules/prosemirror-transform": {
+      "version": "1.12.0",
+      "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz",
+      "integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==",
+      "license": "MIT",
+      "dependencies": {
+        "prosemirror-model": "^1.21.0"
+      }
+    },
+    "node_modules/prosemirror-view": {
+      "version": "1.41.8",
+      "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz",
+      "integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==",
+      "license": "MIT",
+      "dependencies": {
+        "prosemirror-model": "^1.20.0",
+        "prosemirror-state": "^1.0.0",
+        "prosemirror-transform": "^1.1.0"
       }
     },
     "node_modules/proxy-from-env": {
@@ -3019,6 +3748,12 @@
       "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz",
       "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==",
       "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/rope-sequence": {
+      "version": "1.3.4",
+      "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
+      "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
       "license": "MIT"
     },
     "node_modules/scheduler": {
@@ -3239,6 +3974,15 @@
         "punycode": "^2.1.0"
       }
     },
+    "node_modules/use-sync-external-store": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+      "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+      "license": "MIT",
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+      }
+    },
     "node_modules/vite": {
       "version": "8.0.8",
       "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz",
@@ -3317,6 +4061,12 @@
         }
       }
     },
+    "node_modules/w3c-keyname": {
+      "version": "2.2.8",
+      "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
+      "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
+      "license": "MIT"
+    },
     "node_modules/which": {
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
package.json
--- package.json
+++ package.json
@@ -12,7 +12,19 @@
   },
   "dependencies": {
     "@tanstack/react-query": "^5.99.0",
+    "@tiptap/extension-image": "^3.23.5",
+    "@tiptap/extension-link": "^3.23.5",
+    "@tiptap/extension-placeholder": "^3.23.5",
+    "@tiptap/extension-table": "^3.23.5",
+    "@tiptap/extension-table-cell": "^3.23.5",
+    "@tiptap/extension-table-header": "^3.23.5",
+    "@tiptap/extension-table-row": "^3.23.5",
+    "@tiptap/extension-text-align": "^3.23.5",
+    "@tiptap/extension-underline": "^3.23.5",
+    "@tiptap/react": "^3.23.5",
+    "@tiptap/starter-kit": "^3.23.5",
     "axios": "^1.16.0",
+    "dompurify": "^3.4.5",
     "react": "^19.2.4",
     "react-dom": "^19.2.4",
     "react-router-dom": "^7.14.1",
src/App.tsx
--- src/App.tsx
+++ src/App.tsx
@@ -1,8 +1,10 @@
 import { useEffect, useState } from 'react';
+import {Navigate, Route, Routes} from 'react-router-dom';
 import { UserLayout } from './user/UserLayout';
 import { UserListPage } from './user/UserListPage';
 import {AdminRoute} from "./admin/route/AdminRoute.tsx";
 import {ToastContainer} from "react-toastify";
+import {UserContentPreviewPage} from "./user/UserContentPreviewPage.tsx";
 
 type Skin = 'admin' | 'user';
 
@@ -56,9 +58,25 @@
       {skin === 'admin' ? (
         <AdminRoute />
       ) : (
-        <UserLayout>
-          <UserListPage />
-        </UserLayout>
+        <Routes>
+          <Route
+            path="/preview/content"
+            element={(
+              <UserLayout title="콘텐츠 미리보기">
+                <UserContentPreviewPage />
+              </UserLayout>
+            )}
+          />
+          <Route
+            path="/"
+            element={(
+              <UserLayout>
+                <UserListPage />
+              </UserLayout>
+            )}
+          />
+          <Route path="*" element={<Navigate to="/?skin=user" replace />} />
+        </Routes>
       )}
       <ToastContainer position="bottom-right" autoClose={3000} />
     </>
 
src/admin/component/editor/RichTextEditor.tsx (added)
+++ src/admin/component/editor/RichTextEditor.tsx
@@ -0,0 +1,239 @@
+import {EditorContent, useEditor} from '@tiptap/react';
+import StarterKit from '@tiptap/starter-kit';
+import Link from '@tiptap/extension-link';
+import Underline from '@tiptap/extension-underline';
+import TextAlign from '@tiptap/extension-text-align';
+import Placeholder from '@tiptap/extension-placeholder';
+import Image from '@tiptap/extension-image';
+import {Table} from '@tiptap/extension-table';
+import {TableCell} from '@tiptap/extension-table-cell';
+import {TableHeader} from '@tiptap/extension-table-header';
+import {TableRow} from '@tiptap/extension-table-row';
+import {useEffect} from 'react';
+
+type RichTextEditorProps = {
+    value: string;
+    onChange: (value: string) => void;
+    placeholder?: string;
+    disabled?: boolean;
+};
+
+type ToolbarButtonProps = {
+    label: string;
+    title: string;
+    active?: boolean;
+    disabled?: boolean;
+    onClick: () => void;
+};
+
+const ToolbarButton = ({label, title, active = false, disabled = false, onClick}: ToolbarButtonProps) => (
+    <button
+        type="button"
+        className={active ? 'active' : ''}
+        title={title}
+        aria-label={title}
+        disabled={disabled}
+        onClick={onClick}
+    >
+        {label}
+    </button>
+);
+
+export const RichTextEditor = ({
+                                   value,
+                                   onChange,
+                                   placeholder = '내용을 입력하세요.',
+                                   disabled = false,
+                               }: RichTextEditorProps) => {
+    const editor = useEditor({
+        editable: !disabled,
+        extensions: [
+            StarterKit,
+            Underline,
+            Link.configure({
+                openOnClick: false,
+                autolink: true,
+                defaultProtocol: 'https',
+            }),
+            TextAlign.configure({
+                types: ['heading', 'paragraph'],
+            }),
+            Placeholder.configure({
+                placeholder,
+            }),
+            Image.configure({
+                allowBase64: true,
+            }),
+            Table.configure({
+                resizable: true,
+            }),
+            TableRow,
+            TableHeader,
+            TableCell,
+        ],
+        content: value || '',
+        editorProps: {
+            attributes: {
+                class: 'rich_text_editor_body',
+            },
+        },
+        onUpdate: ({editor}) => {
+            onChange(editor.getHTML());
+        },
+    });
+
+    useEffect(() => {
+        if (!editor) {
+            return;
+        }
+
+        editor.setEditable(!disabled);
+    }, [disabled, editor]);
+
+    useEffect(() => {
+        if (!editor || value === editor.getHTML()) {
+            return;
+        }
+
+        editor.commands.setContent(value || '', {emitUpdate: false});
+    }, [editor, value]);
+
+    if (!editor) {
+        return null;
+    }
+
+    const setLink = () => {
+        const previousUrl = editor.getAttributes('link').href as string | undefined;
+        const url = window.prompt('링크 URL을 입력하세요.', previousUrl ?? '');
+
+        if (url === null) {
+            return;
+        }
+
+        if (!url.trim()) {
+            editor.chain().focus().extendMarkRange('link').unsetLink().run();
+            return;
+        }
+
+        editor.chain().focus().extendMarkRange('link').setLink({href: url.trim()}).run();
+    };
+
+    const addImage = () => {
+        const url = window.prompt('이미지 URL을 입력하세요.');
+
+        if (!url?.trim()) {
+            return;
+        }
+
+        editor.chain().focus().setImage({src: url.trim()}).run();
+    };
+
+    return (
+        <div className="rich_text_editor">
+            <div className="rich_text_editor_toolbar">
+                <ToolbarButton
+                    label="H2"
+                    title="제목"
+                    active={editor.isActive('heading', {level: 2})}
+                    disabled={disabled}
+                    onClick={() => editor.chain().focus().toggleHeading({level: 2}).run()}
+                />
+                <ToolbarButton
+                    label="B"
+                    title="굵게"
+                    active={editor.isActive('bold')}
+                    disabled={disabled}
+                    onClick={() => editor.chain().focus().toggleBold().run()}
+                />
+                <ToolbarButton
+                    label="I"
+                    title="기울임"
+                    active={editor.isActive('italic')}
+                    disabled={disabled}
+                    onClick={() => editor.chain().focus().toggleItalic().run()}
+                />
+                <ToolbarButton
+                    label="U"
+                    title="밑줄"
+                    active={editor.isActive('underline')}
+                    disabled={disabled}
+                    onClick={() => editor.chain().focus().toggleUnderline().run()}
+                />
+                <ToolbarButton
+                    label="S"
+                    title="취소선"
+                    active={editor.isActive('strike')}
+                    disabled={disabled}
+                    onClick={() => editor.chain().focus().toggleStrike().run()}
+                />
+                <ToolbarButton
+                    label="•"
+                    title="글머리 기호"
+                    active={editor.isActive('bulletList')}
+                    disabled={disabled}
+                    onClick={() => editor.chain().focus().toggleBulletList().run()}
+                />
+                <ToolbarButton
+                    label="1."
+                    title="번호 목록"
+                    active={editor.isActive('orderedList')}
+                    disabled={disabled}
+                    onClick={() => editor.chain().focus().toggleOrderedList().run()}
+                />
+                <ToolbarButton
+                    label="L"
+                    title="왼쪽 정렬"
+                    active={editor.isActive({textAlign: 'left'})}
+                    disabled={disabled}
+                    onClick={() => editor.chain().focus().setTextAlign('left').run()}
+                />
+                <ToolbarButton
+                    label="C"
+                    title="가운데 정렬"
+                    active={editor.isActive({textAlign: 'center'})}
+                    disabled={disabled}
+                    onClick={() => editor.chain().focus().setTextAlign('center').run()}
+                />
+                <ToolbarButton
+                    label="R"
+                    title="오른쪽 정렬"
+                    active={editor.isActive({textAlign: 'right'})}
+                    disabled={disabled}
+                    onClick={() => editor.chain().focus().setTextAlign('right').run()}
+                />
+                <ToolbarButton
+                    label="Link"
+                    title="링크"
+                    active={editor.isActive('link')}
+                    disabled={disabled}
+                    onClick={setLink}
+                />
+                <ToolbarButton
+                    label="Img"
+                    title="이미지"
+                    disabled={disabled}
+                    onClick={addImage}
+                />
+                <ToolbarButton
+                    label="Tbl"
+                    title="표 삽입"
+                    disabled={disabled}
+                    onClick={() => editor.chain().focus().insertTable({rows: 3, cols: 3, withHeaderRow: true}).run()}
+                />
+                <ToolbarButton
+                    label="Undo"
+                    title="실행 취소"
+                    disabled={disabled || !editor.can().undo()}
+                    onClick={() => editor.chain().focus().undo().run()}
+                />
+                <ToolbarButton
+                    label="Redo"
+                    title="다시 실행"
+                    disabled={disabled || !editor.can().redo()}
+                    onClick={() => editor.chain().focus().redo().run()}
+                />
+            </div>
+            <EditorContent editor={editor}/>
+        </div>
+    );
+};
 
src/admin/feature/content/api/contentApi.ts (added)
+++ src/admin/feature/content/api/contentApi.ts
@@ -0,0 +1,34 @@
+import type {
+    ContentDetailParams,
+    ContentFormItem,
+    ContentListItem,
+    ContentSearchParams,
+    DeleteBatchContentRequest
+} from "../type/content.types.ts";
+import {apiClient} from "../../../../api/apiClient.ts";
+import type {PageResponse} from "../../../../type/pageResponse.ts";
+
+export async function fetchContentList(params: ContentSearchParams) {
+    return apiClient.get<PageResponse<ContentListItem>>('/uss/ion/cnt/list.do', params);
+}
+
+export async function fetchContentDetail(params: ContentDetailParams) {
+    return apiClient.get<ContentFormItem>('/uss/ion/cnt/detail.do', params);
+}
+
+export async function fetchContentCreate(params: ContentFormItem) {
+    return apiClient.post('/uss/ion/cnt/egovCntManageInsert.do', params);
+}
+
+export async function fetchContentUpdate(params: ContentFormItem) {
+    return apiClient.post('/uss/ion/cnt/egovCntManageUpdate.do', params);
+}
+
+export async function fetchContentDelete(cntId: string) {
+    return apiClient.post(`/uss/ion/cnt/egovCntManageDelete.do?cntId=${cntId}`);
+}
+
+export async function fetchContentDeleteBatch(params: DeleteBatchContentRequest[]) {
+    return apiClient.post('/uss/ion/cnt/egovCntManageDeleteBatch.do', params);
+}
+
 
src/admin/feature/content/component/ContentFormTable.tsx (added)
+++ src/admin/feature/content/component/ContentFormTable.tsx
@@ -0,0 +1,91 @@
+import type {ChangeEvent} from 'react';
+import {RichTextEditor} from '../../../component/editor/RichTextEditor.tsx';
+import type {ContentFormItem} from '../type/content.types.ts';
+
+type ContentFormTableProps = {
+    form: ContentFormItem;
+    onChange: (event: ChangeEvent<HTMLInputElement>) => void;
+    onContentChange: (value: string) => void;
+    disabled?: boolean;
+};
+
+export const ContentFormTable = ({
+                                     form,
+                                     onChange,
+                                     onContentChange,
+                                     disabled = false,
+                                 }: ContentFormTableProps) => {
+    return (
+        <div className="table table_type_rows">
+            <table>
+                <colgroup>
+                    <col style={{width: '200px'}}/>
+                    <col style={{width: 'auto'}}/>
+                </colgroup>
+
+                <tbody>
+                <tr>
+                    <th>
+                        <span className="required">*</span>
+                        콘텐츠 이름
+                    </th>
+                    <td>
+                        <input
+                            type="text"
+                            className="input"
+                            id="cntName"
+                            name="cntName"
+                            value={form.cntName}
+                            onChange={onChange}
+                            disabled={disabled}
+                        />
+                    </td>
+                </tr>
+
+                <tr>
+                    <th>
+                        <span className="required">*</span>
+                        내용
+                    </th>
+                    <td>
+                        <RichTextEditor
+                            value={form.cntCn}
+                            onChange={onContentChange}
+                            disabled={disabled}
+                        />
+                    </td>
+                </tr>
+
+                {form.registPnttm ? (
+                    <tr>
+                        <th>최종수정일</th>
+                        <td>
+                            <input
+                                type="text"
+                                className="input"
+                                value={form.registPnttm}
+                                readOnly
+                            />
+                        </td>
+                    </tr>
+                ) : null}
+
+                {form.registerId ? (
+                    <tr>
+                        <th>작성자</th>
+                        <td>
+                            <input
+                                type="text"
+                                className="input"
+                                value={form.registerId}
+                                readOnly
+                            />
+                        </td>
+                    </tr>
+                ) : null}
+
+                </tbody>
+            </table>
+        </div>
+    );
+};
 
src/admin/feature/content/component/ContentListTable.tsx (added)
+++ src/admin/feature/content/component/ContentListTable.tsx
@@ -0,0 +1,55 @@
+import type {CheckableTableModel, RowActionsModel} from "../../../../type/viewModel.ts";
+import type {ContentListItem} from "../type/content.types.ts";
+import type {SearchParams} from "../../../../type/searchParams.ts";
+import {ContentListTableHeader} from "./ContentListTableHeader.tsx";
+import {EmptyRow} from "../../../component/EmptyRow.tsx";
+import {ContentListTableRow} from "./ContentListTableRow.tsx";
+
+type ContentListTableProps = CheckableTableModel<ContentListItem, SearchParams> & RowActionsModel<{
+    onDetail: (cntId: string, cntDtId: string) => void
+    onPreview: (cntId: string, cntDtId: string) => void
+}>;
+
+
+export const ContentListTable = ({
+                                     items,
+                                     params,
+                                     onChange,
+                                     pagination,
+                                     check,
+                                     rowActions
+                                 }: ContentListTableProps) => {
+    return (
+        <div className={"table table_type_cols"}>
+            <table>
+                <ContentListTableHeader
+                    params={params}
+                    onChange={onChange}
+                    checked={check.isAllChecked}
+                    indeterminate={check.isPartiallyChecked}
+                    onCheckAll={check.onCheckAll}
+                />
+
+                <tbody>
+                {items.length > 0 ?
+                    items.map((item, index) => (
+                            <ContentListTableRow
+                                key={index}
+                                item={item}
+                                index={index}
+                                searchParams={params}
+                                {...pagination}
+                                checked={check.isChecked(item.cntId)}
+                                onCheck={check.onCheck}
+                                {...rowActions}
+                            />
+                        )
+                    ) : (
+                        <EmptyRow colSpan={7}/>
+                    )
+                }
+                </tbody>
+            </table>
+        </div>
+    )
+}
 
src/admin/feature/content/component/ContentListTableHeader.tsx (added)
+++ src/admin/feature/content/component/ContentListTableHeader.tsx
@@ -0,0 +1,53 @@
+import {useTableSort} from "../../../hook/useTableSort.ts";
+import type {SearchParams} from "../../../../type/searchParams.ts";
+import {CheckBox} from "../../../component/checkbox/CheckBox.tsx";
+import {SortableHeaderCell} from "../../../component/table/SortableHeaderCell.tsx";
+
+interface ContentListTableHeaderProps {
+    params: SearchParams;
+    onChange: (params: SearchParams) => void
+    checked: boolean
+    indeterminate: boolean
+    onCheckAll: (checked: boolean) => void
+}
+
+export const ContentListTableHeader = ({
+                                           params,
+                                           onChange,
+                                           checked,
+                                           indeterminate,
+                                           onCheckAll
+                                       }: ContentListTableHeaderProps) => {
+    const {handleSort, getSortIcon, isSorted} = useTableSort(params, onChange);
+
+    return (
+        <>
+            <colgroup>
+                <col style={{width: "40px"}}/>
+                <col style={{width: "6%"}}/>
+                <col style={{width: "calc((54%/2) - 40px)"}}/>
+                <col style={{width: "calc((54%/2) - 40px)"}}/>
+                <col style={{width: "10%"}}/>
+                <col style={{width: "20%"}}/>
+                <col style={{width: "10%"}}/>
+            </colgroup>
+            <thead>
+            <tr>
+                <th>
+                    <CheckBox id="contentCheckAll"
+                              name="checkAll"
+                              checked={checked}
+                              onChange={onCheckAll}
+                              indeterminate={indeterminate}/>
+                </th>
+                <SortableHeaderCell field={"cntId"} active={isSorted("cntId")} icon={getSortIcon("cntId")} onSort={handleSort}>번호</SortableHeaderCell>
+                <SortableHeaderCell field={"cntName"} active={isSorted("cntName")} icon={getSortIcon("cntName")} onSort={handleSort}>콘텐츠이름</SortableHeaderCell>
+                <SortableHeaderCell field={"menuNm"} active={isSorted("menuNm")} icon={getSortIcon("menuNm")} onSort={handleSort}>연결메뉴</SortableHeaderCell>
+                <SortableHeaderCell field={"registerId"} active={isSorted("registerId")} icon={getSortIcon("registerId")} onSort={handleSort}>등록자</SortableHeaderCell>
+                <SortableHeaderCell field={"registPnttm"} active={isSorted("registPnttm")} icon={getSortIcon("registPnttm")} onSort={handleSort}>등록일자</SortableHeaderCell>
+                <th scope={"col"}>미리보기</th>
+            </tr>
+            </thead>
+        </>
+    )
+}(No newline at end of file)
 
src/admin/feature/content/component/ContentListTableRow.tsx (added)
+++ src/admin/feature/content/component/ContentListTableRow.tsx
@@ -0,0 +1,76 @@
+import type {ContentListItem} from "../type/content.types.ts";
+import type {SearchParams} from "../../../../type/searchParams.ts";
+import {CheckBox} from "../../../component/checkbox/CheckBox.tsx";
+import {getTableRowNumber} from "../../../component/table/getTableRowNumber.ts";
+
+interface ContentListTableRowProps {
+    item: ContentListItem
+    index: number;
+    searchParams: SearchParams;
+    totalItems: number
+    currentPage: number
+    checked: boolean
+    onCheck: (id: string, checked: boolean) => void
+    onDetail: (cntId: string, cntDtId: string) => void
+    onPreview: (cntId: string, cntDtId: string) => void
+}
+
+export const ContentListTableRow = (
+    {
+        item,
+        index,
+        searchParams,
+        totalItems,
+        currentPage,
+        checked,
+        onCheck,
+        onDetail,
+        onPreview
+    }: ContentListTableRowProps
+) => {
+
+    const rowNumber = getTableRowNumber({
+        searchParams,
+        totalItems,
+        currentPage,
+        index
+    })
+
+    const cntId = item.cntId;
+    const cntDtId = item.cntDtId;
+
+    return (
+        <tr>
+            <td>
+                <CheckBox id={`contentCheckList_${cntId}`}
+                          name={'checkList'}
+                          value={cntId}
+                          checked={checked}
+                          onChange={(nextChecked => onCheck(cntId, nextChecked))}/>
+            </td>
+            <td>
+                {rowNumber}
+            </td>
+
+            <td>
+                <button onClick={() => onDetail(cntId, cntDtId)}>
+                    {item.cntName}
+                </button>
+            </td>
+            <td>
+                {item.menuNm}
+            </td>
+            <td>
+                {item.registerId}
+            </td>
+            <td>
+                {item.registPnttm}
+            </td>
+            <td>
+                <button className={"btn line primary small"} onClick={() => onPreview(cntId, cntDtId)}>
+                    미리보기
+                </button>
+            </td>
+        </tr>
+    )
+}
 
src/admin/feature/content/hook/mutation/useCreateContent.ts (added)
+++ src/admin/feature/content/hook/mutation/useCreateContent.ts
@@ -0,0 +1,15 @@
+import {useMutation, useQueryClient} from "@tanstack/react-query";
+import {fetchContentCreate} from "../../api/contentApi.ts";
+
+export const useCreateContent = () => {
+    const queryClient = useQueryClient();
+
+    return useMutation({
+        mutationFn: fetchContentCreate,
+        onSuccess: () => {
+            queryClient.invalidateQueries({
+                queryKey: ['contentList'],
+            });
+        },
+    });
+};
 
src/admin/feature/content/hook/mutation/useDeleteBatchContent.ts (added)
+++ src/admin/feature/content/hook/mutation/useDeleteBatchContent.ts
@@ -0,0 +1,15 @@
+import {useMutation, useQueryClient} from "@tanstack/react-query";
+import {fetchContentDeleteBatch} from "../../api/contentApi.ts";
+
+export const useDeleteBatchContent = () => {
+    const queryClient = useQueryClient();
+
+    return useMutation({
+        mutationFn: fetchContentDeleteBatch,
+        onSuccess: () => {
+            queryClient.invalidateQueries({
+                queryKey: ['contentList'],
+            })
+        }
+    })
+}(No newline at end of file)
 
src/admin/feature/content/hook/mutation/useDeleteContent.ts (added)
+++ src/admin/feature/content/hook/mutation/useDeleteContent.ts
@@ -0,0 +1,15 @@
+import {useMutation, useQueryClient} from "@tanstack/react-query";
+import {fetchContentDelete} from "../../api/contentApi.ts";
+
+export const useDeleteContent = () => {
+    const queryClient = useQueryClient();
+
+    return useMutation({
+        mutationFn: fetchContentDelete,
+        onSuccess: () => {
+            queryClient.invalidateQueries({
+                queryKey: ['contentList'],
+            });
+        },
+    });
+};
 
src/admin/feature/content/hook/mutation/useUpdateContent.ts (added)
+++ src/admin/feature/content/hook/mutation/useUpdateContent.ts
@@ -0,0 +1,18 @@
+import {useMutation, useQueryClient} from "@tanstack/react-query";
+import {fetchContentUpdate} from "../../api/contentApi.ts";
+
+export const useUpdateContent = () => {
+    const queryClient = useQueryClient();
+
+    return useMutation({
+        mutationFn: fetchContentUpdate,
+        onSuccess: () => {
+            queryClient.invalidateQueries({
+                queryKey: ['contentList'],
+            });
+            queryClient.invalidateQueries({
+                queryKey: ['contentDetail'],
+            });
+        },
+    });
+};
 
src/admin/feature/content/hook/page/useContentFormPage.ts (added)
+++ src/admin/feature/content/hook/page/useContentFormPage.ts
@@ -0,0 +1,184 @@
+import {type ChangeEvent, useMemo, useState} from "react";
+import {useNavigate} from "react-router-dom";
+import {toast} from "react-toastify";
+import type {FormActionsModel, FormMode, HeaderModel, StatusModel} from "../../../../../type/viewModel.ts";
+import {ADMIN_CONTENT_LIST_ROUTE} from "../../../../route/adminRouteMap.ts";
+import type {ContentFormItem} from "../../type/content.types.ts";
+import {useContentDetail} from "../query/useContentDetail.ts";
+import {useCreateContent} from "../mutation/useCreateContent.ts";
+import {useUpdateContent} from "../mutation/useUpdateContent.ts";
+import {useDeleteContent} from "../mutation/useDeleteContent.ts";
+
+type ContentFormPageModel = {
+    header: HeaderModel;
+    status: StatusModel;
+    form: {
+        form: ContentFormItem;
+        onChange: (event: ChangeEvent<HTMLInputElement>) => void;
+        onContentChange: (value: string) => void;
+        disabled: boolean;
+    };
+    actions: FormActionsModel<FormMode>;
+};
+
+const initContentFormData: ContentFormItem = {
+    cntId: '',
+    cntDtId: '',
+    cntName: '',
+    cntCn: '',
+    registerId: '',
+    registPnttm: '',
+    updusrId: '',
+    updtPnttm: '',
+};
+
+const createInitialForm = (
+    item?: ContentFormItem,
+    cntId = '',
+    cntDtId = '',
+) => ({
+    ...initContentFormData,
+    cntId,
+    cntDtId,
+    ...item,
+});
+
+export const useContentFormPage = (cntId: string, cntDtId: string): ContentFormPageModel => {
+    const navigate = useNavigate();
+    const mode: FormMode = cntId ? 'update' : 'create';
+    const [formDraft, setFormDraft] = useState<Partial<ContentFormItem>>({});
+
+    const {data, isLoading, error} = useContentDetail(cntId, cntDtId, {enabled: !!cntId});
+    console.log(data);
+    const {mutateAsync: createContent, isPending: isCreating} = useCreateContent();
+    const {mutateAsync: updateContent, isPending: isUpdating} = useUpdateContent();
+    const {mutateAsync: deleteContent, isPending: isDeleting} = useDeleteContent();
+    const isPending = isCreating || isUpdating || isDeleting;
+
+    const title = `콘텐츠 ${mode === 'create' ? '등록' : '수정'}`;
+    const breadcrumb = [
+        {label: '콘텐츠 관리', url: ADMIN_CONTENT_LIST_ROUTE},
+        {label: title},
+    ];
+
+    const baseForm = useMemo(
+        () => createInitialForm(data, cntId, cntDtId),
+        [cntDtId, cntId, data],
+    );
+    const form = {
+        ...baseForm,
+        ...formDraft,
+    };
+
+    const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
+        const {name, value} = event.target;
+
+        setFormDraft((prev) => ({
+            ...prev,
+            [name]: value,
+        }));
+    };
+
+    const handleContentChange = (value: string) => {
+        setFormDraft((prev) => ({
+            ...prev,
+            cntCn: value,
+        }));
+    };
+
+    const validateForm = () => {
+        if (!form.cntName.trim()) {
+            toast.warning('콘텐츠 이름을 입력해주세요.');
+            return false;
+        }
+
+        if (!form.cntCn.trim() || form.cntCn === '<p></p>') {
+            toast.warning('내용을 입력해주세요.');
+            return false;
+        }
+
+        return true;
+    };
+
+    const handleCreate = async () => {
+        if (!validateForm()) {
+            return;
+        }
+
+        await toast.promise(
+            createContent(form),
+            {
+                pending: '등록 중...',
+                success: '등록 완료',
+                error: '등록 실패',
+            },
+        );
+
+        handleList();
+    };
+
+    const handleUpdate = async () => {
+        if (!validateForm()) {
+            return;
+        }
+
+        await toast.promise(
+            updateContent(form),
+            {
+                pending: '수정 중...',
+                success: '수정 완료',
+                error: '수정 실패',
+            },
+        );
+
+        handleList();
+    };
+
+    const handleDelete = async () => {
+        if (!cntId || !window.confirm('콘텐츠를 삭제하시겠습니까?')) {
+            return;
+        }
+
+        await toast.promise(
+            deleteContent(cntId),
+            {
+                pending: '삭제 중...',
+                success: '삭제 완료',
+                error: '삭제 실패',
+            },
+        );
+
+        handleList();
+    };
+
+    const handleList = () => {
+        navigate(ADMIN_CONTENT_LIST_ROUTE);
+    };
+
+    return {
+        header: {
+            title,
+            breadcrumb,
+            homeUrl: '#',
+        },
+        status: {
+            isLoading,
+            error,
+            successMessage: '콘텐츠 조회가 완료되었습니다.',
+        },
+        form: {
+            form,
+            onChange: handleChange,
+            onContentChange: handleContentChange,
+            disabled: isPending,
+        },
+        actions: {
+            mode,
+            disabled: isPending,
+            onCreate: handleCreate,
+            onUpdate: handleUpdate,
+            onDelete: handleDelete,
+            onList: handleList,
+        },
+    };
+};
 
src/admin/feature/content/hook/page/useContentListPage.ts (added)
+++ src/admin/feature/content/hook/page/useContentListPage.ts
@@ -0,0 +1,176 @@
+import type {ContentListItem, ContentSearchParams} from "../../type/content.types.ts";
+import type {
+    CheckableTableModel,
+    HeaderModel, ListActionsModel, PaginationModel,
+    RowActionsModel,
+    SearchModel,
+    StatusModel
+} from "../../../../../type/viewModel.ts";
+import {useMemo, useState} from "react";
+import {useContentList} from "../query/useContentList.ts";
+import {useCheckedList} from "../../../../hook/useCheckedList.ts";
+import {toast} from "react-toastify";
+import {useNavigate} from "react-router-dom";
+import {useDeleteBatchContent} from "../mutation/useDeleteBatchContent.ts";
+import {ADMIN_CONTENT_FORM_ROUTE} from "../../../../route/adminRouteMap.ts";
+
+type ContentListRowActions = {
+    onDetail: (cntId: string, cntDtId: string) => void,
+    onPreview: (cntId: string, cntDtId: string) => void,
+}
+
+type ContentListPageModel = {
+    header: HeaderModel;
+    status: StatusModel;
+    search: SearchModel<ContentSearchParams>
+    table: CheckableTableModel<ContentListItem, ContentSearchParams> & RowActionsModel<ContentListRowActions>
+    actions: ListActionsModel;
+    pagination: PaginationModel;
+}
+
+const initSearchParams: ContentSearchParams = {
+    pageIndex: 1,
+    pageUnit: 10,
+    searchCnd: "0",
+    searchKeyword: "",
+    searchSortCnd: "",
+    searchSortOrd: ""
+}
+
+const pageSizeOptions = [
+    {value: '10', label: '10줄'},
+    {value: '20', label: '20줄'},
+    {value: '30', label: '30줄'},
+]
+
+const title = "콘텐츠관리"
+const breadcrumb = [
+    {label: "콘텐츠관리"}
+]
+
+export const useContentListPage = (): ContentListPageModel => {
+    const navigate = useNavigate();
+    const [searchParams, setSearchParams] = useState(initSearchParams);
+    const {
+        list,
+        totalItems,
+        totalPages,
+        currentPage,
+        size,
+        isLoading,
+        error
+    } = useContentList(searchParams);
+    const {mutateAsync: deleteBatchContent} = useDeleteBatchContent();
+
+    const contentIds = useMemo(() => list.map((item) => item.cntId), [list]);
+
+    const {
+        checkedIds,
+        isAllChecked,
+        isPartiallyChecked,
+        isChecked,
+        handleCheck,
+        handleCheckAll
+    } = useCheckedList(contentIds);
+
+    const handleDetail = (cntId:string, cntDtId: string) => {
+        navigate(`${ADMIN_CONTENT_FORM_ROUTE}?cntId=${cntId}&cntDtId=${cntDtId}`);
+    }
+
+    const handlePreview = (cntId: string, cntDtId: string) => {
+        const params = new URLSearchParams({
+            skin: 'user',
+            cntId,
+            cntDtId,
+        });
+        const previewUrl = `/preview/content?${params.toString()}`;
+
+        window.open(
+            previewUrl,
+            'contentPreview',
+            'width=1200,height=900,top=80,left=120,scrollbars=yes,resizable=yes',
+        );
+    }
+
+    const handleDeleteBatch = async () => {
+        if(checkedIds.length === 0) {
+            toast.warning('삭제할 컨텐츠를 선택해주세요');
+            return;
+        }
+
+        const contentList = checkedIds.map((cntId) => ({
+            cntId,
+        }));
+
+        await toast.promise(
+            deleteBatchContent(contentList),
+            {
+                pending: '삭제 처리중...',
+                success: '삭제 완료',
+                error: '삭제 실패'
+            }
+        )
+    }
+
+    const handleCreate = () => {
+        navigate(`${ADMIN_CONTENT_FORM_ROUTE}`);
+    }
+
+    const handlePageChange = (pageIndex: number) => {
+        setSearchParams((prev) =>({
+            ...prev,
+            pageIndex
+        }));
+    }
+
+    return {
+        header: {
+            title,
+            breadcrumb,
+            homeUrl: '#',
+        },
+        status : {
+            isLoading,
+            error,
+            successMessage: '콘텐츠의 조회가 완료되었습니다.'
+        },
+        search: {
+            totalItems,
+            searchParams,
+            onChange: setSearchParams,
+            pageSizeOptions,
+        },
+        table: {
+            items: list,
+            params: searchParams,
+            onChange: setSearchParams,
+            pagination: {
+                totalItems,
+                currentPage,
+                totalPages,
+            },
+            check: {
+                isAllChecked,
+                isPartiallyChecked,
+                isChecked,
+                onCheck: handleCheck,
+                onCheckAll: handleCheckAll,
+            },
+            rowActions: {
+                onDetail: handleDetail,
+                onPreview: handlePreview,
+            }
+        },
+        actions: {
+            onDelete: handleDeleteBatch,
+            onCreate: handleCreate
+        },
+        pagination: {
+            totalItems,
+            totalPages,
+            currentPage,
+            size,
+            onPageChange: handlePageChange
+        }
+    }
+}
 
src/admin/feature/content/hook/query/useContentDetail.ts (added)
+++ src/admin/feature/content/hook/query/useContentDetail.ts
@@ -0,0 +1,19 @@
+import {keepPreviousData, useQuery} from '@tanstack/react-query';
+import {fetchContentDetail} from '../../api/contentApi.ts';
+
+type UseContentDetailOptions = {
+    enabled: boolean;
+};
+
+export const useContentDetail = (
+    cntId: string,
+    cntDtId: string,
+    options?: UseContentDetailOptions,
+) => {
+    return useQuery({
+        queryKey: ['contentDetail', cntId, cntDtId],
+        queryFn: () => fetchContentDetail({cntId, cntDtId}),
+        placeholderData: keepPreviousData,
+        enabled: options?.enabled ?? true,
+    });
+};
 
src/admin/feature/content/hook/query/useContentList.ts (added)
+++ src/admin/feature/content/hook/query/useContentList.ts
@@ -0,0 +1,14 @@
+import {keepPreviousData, useQuery} from "@tanstack/react-query";
+import {fetchContentList} from "../../api/contentApi.ts";
+import type {SearchParams} from "../../../../../type/searchParams.ts";
+import {createPageQueryResult} from "../../../../../type/pageResponse.ts";
+
+export const useContentList = (searchParams: SearchParams) => {
+    const query = useQuery({
+        queryKey: ['contentList', searchParams],
+        queryFn: () => fetchContentList(searchParams),
+        placeholderData: keepPreviousData
+    });
+
+    return createPageQueryResult(query);
+}(No newline at end of file)
 
src/admin/feature/content/page/ContentFormPage.tsx (added)
+++ src/admin/feature/content/page/ContentFormPage.tsx
@@ -0,0 +1,28 @@
+import {useSearchParams} from "react-router-dom";
+import {PageHeader} from "../../../component/PageHeader.tsx";
+import {useLoadingToast} from "../../../hook/useLoadingToast.ts";
+import {ActionButtonFormGroup} from "../../../component/button/ActionButtonFormGroup.tsx";
+import {useContentFormPage} from "../hook/page/useContentFormPage.ts";
+import {ContentFormTable} from "../component/ContentFormTable.tsx";
+
+export const ContentFormPage = () => {
+    const [urlParams] = useSearchParams();
+    const cntId = urlParams.get("cntId") || "";
+    const cntDtId = urlParams.get("cntDtId") || "";
+    const {
+        header,
+        status,
+        form,
+        actions,
+    } = useContentFormPage(cntId, cntDtId);
+
+    useLoadingToast(status);
+
+    return (
+        <>
+            <PageHeader {...header}/>
+            <ContentFormTable {...form}/>
+            <ActionButtonFormGroup {...actions}/>
+        </>
+    );
+}
 
src/admin/feature/content/page/ContentListPage.tsx (added)
+++ src/admin/feature/content/page/ContentListPage.tsx
@@ -0,0 +1,24 @@
+import {useContentListPage} from "../hook/page/useContentListPage.ts";
+import {PageHeader} from "../../../component/PageHeader.tsx";
+import {ListSearchForm} from "../../../component/ListSearchForm.tsx";
+import {Pagination} from "../../../component/pagination/Pagination.tsx";
+import {ActionButtonListGroup} from "../../../component/button/ActionButtonListGroup.tsx";
+import {useLoadingToast} from "../../../hook/useLoadingToast.ts";
+import {ContentListTable} from "../component/ContentListTable.tsx";
+
+export const ContentListPage = () => {
+    const {header, search, status, actions, pagination, table} = useContentListPage();
+
+    useLoadingToast(status);
+    return (
+        <>
+            <PageHeader {...header} />
+            <ListSearchForm {...search}
+                            totalLabel="총 게시물"
+            />
+            <ContentListTable {...table} />
+            <ActionButtonListGroup {...actions} />
+            <Pagination {...pagination} />
+        </>
+    )
+}(No newline at end of file)
 
src/admin/feature/content/type/content.types.ts (added)
+++ src/admin/feature/content/type/content.types.ts
@@ -0,0 +1,29 @@
+import type {SearchParams} from "../../../../type/searchParams.ts";
+
+export type ContentSearchParams = SearchParams;
+
+export interface ContentListItem {
+    cntId: string;
+    cntDtId: string;
+    cntName: string;
+    menuNm: string;
+    registerId: string;
+    registPnttm: string;
+}
+
+export interface ContentFormItem {
+    cntId: string;
+    cntDtId: string;
+    cntName: string;
+    cntCn: string;
+    registerId: string;
+    registPnttm: string;
+    updusrId: string;
+    updtPnttm: string;
+}
+
+export interface DeleteBatchContentRequest {
+    cntId: string;
+}
+
+export type ContentDetailParams = Pick<ContentFormItem, 'cntId' | 'cntDtId'>;
src/admin/feature/role/author/api/authorApi.ts
--- src/admin/feature/role/author/api/authorApi.ts
+++ src/admin/feature/role/author/api/authorApi.ts
@@ -1,11 +1,32 @@
 import {apiClient} from "../../../../../api/apiClient.ts";
 import type {PageResponse} from "../../../../../type/pageResponse.ts";
-import type {AuthorListItem, AuthorSearchParams} from "../type/author.types.ts";
+import type {
+    AuthorFormItem,
+    AuthorListItem,
+    AuthorSearchParams,
+    DeleteBatchAuthorRequest
+} from "../type/author.types.ts";
 
 export async function fetchAuthorList(params: AuthorSearchParams) {
     return apiClient.get<PageResponse<AuthorListItem>>(`/sec/ram/list.do`, params);
 }
 
 export async function fetchAuthorDetail(authorCode: string) {
-    return apiClient.get(`/sec/ram/detail.do?authorCode=${authorCode}`);
+    return apiClient.get<AuthorFormItem>(`/sec/ram/detail.do?authorCode=${authorCode}`);
 }
+
+export async function fetchCreateAuthor(params: AuthorFormItem) {
+    return apiClient.post('/sec/ram/EgovAuthorInsert.do', params);
+}
+
+export async function fetchUpdateAuthor(params: AuthorFormItem) {
+    return apiClient.post('/sec/ram/EgovAuthorUpdate.do', params);
+}
+
+export async function fetchDeleteAuthor(authorCode: string){
+    return apiClient.post(`/sec/ram/EgovAuthorDelete.do?authorCode=${authorCode}`);
+}
+
+export async function fetchDeleteBatchAuthor(params: DeleteBatchAuthorRequest[]) {
+    return apiClient.post("/sec/ram/EgovAuthorDeleteBatch.do", params);
+}
(No newline at end of file)
 
src/admin/feature/role/author/components/AuthorFormTable.tsx (added)
+++ src/admin/feature/role/author/components/AuthorFormTable.tsx
@@ -0,0 +1,69 @@
+import type {ChangeEvent} from "react";
+import type {AuthorFormItem} from "../type/author.types.ts";
+
+type AuthorFormTableProps = {
+    form: AuthorFormItem,
+    onChange: (event: ChangeEvent<HTMLInputElement>) => void,
+    mode?: "create" | "update"
+}
+
+export const AuthorFormTable = ({form, onChange, mode}: AuthorFormTableProps) => {
+    return (
+        <div className={"table table_type_rows"}>
+            <table>
+                <colgroup>
+                    <col style={{width: '200px'}}/>
+                    <col style={{width: 'auto'}}/>
+                </colgroup>
+
+                <tbody>
+                <tr>
+                    <th>
+                        <span className={"required"}>*</span>
+                        권한코드
+                    </th>
+                    <td>
+                        <input type="text"
+                               name="authorCode"
+                               id="authorCode"
+                               className="input"
+                               value={form.authorCode}
+                               onChange={onChange}
+                               title="권한코드"
+                               readOnly={mode === "update"}
+                        />
+                    </td>
+                </tr>
+                <tr>
+                    <th>
+                        <span className={"required"}>*</span>
+                        권한명
+                    </th>
+                    <td>
+                        <input name="authorNm"
+                               id="authorNm"
+                               className="input"
+                               type="text"
+                               value={form.authorNm}
+                               onChange={onChange}
+                               title="권한명"/>
+                    </td>
+                </tr>
+                <tr>
+                    <th>설명</th>
+                    <td>
+                        <input name="authorDc"
+                               id="authorDc"
+                               className="input"
+                               type="text"
+                               value={form.authorDc}
+                               onChange={onChange}
+                               title="설명"/>
+                    </td>
+                </tr>
+                </tbody>
+
+            </table>
+        </div>
+    )
+}(No newline at end of file)
 
src/admin/feature/role/author/hook/mutation/useCreateAuthor.ts (added)
+++ src/admin/feature/role/author/hook/mutation/useCreateAuthor.ts
@@ -0,0 +1,15 @@
+import {useMutation, useQueryClient} from "@tanstack/react-query";
+import {fetchCreateAuthor} from "../../api/authorApi.ts";
+
+export const useCreateAuthor = () => {
+    const queryClient = useQueryClient();
+
+    return useMutation({
+        mutationFn: fetchCreateAuthor,
+        onSuccess: () => {
+            queryClient.invalidateQueries({
+                queryKey: ['authorDetail']
+            })
+        }
+    })
+}(No newline at end of file)
 
src/admin/feature/role/author/hook/mutation/useDeleteAuthor.ts (added)
+++ src/admin/feature/role/author/hook/mutation/useDeleteAuthor.ts
@@ -0,0 +1,15 @@
+import {useMutation, useQueryClient} from "@tanstack/react-query";
+import {fetchDeleteAuthor} from "../../api/authorApi.ts";
+
+export const useDeleteAuthor = () => {
+    const queryClient = useQueryClient();
+
+    return useMutation({
+        mutationFn: fetchDeleteAuthor,
+        onSuccess: () => {
+            queryClient.invalidateQueries({
+                queryKey: ['authorDetail']
+            });
+        }
+    });
+}(No newline at end of file)
 
src/admin/feature/role/author/hook/mutation/useDeleteBatchAuthor.ts (added)
+++ src/admin/feature/role/author/hook/mutation/useDeleteBatchAuthor.ts
@@ -0,0 +1,15 @@
+import {useMutation, useQueryClient} from "@tanstack/react-query";
+import {fetchDeleteBatchAuthor} from "../../api/authorApi.ts";
+
+export const useDeleteBatchAuthor = () => {
+    const queryClient = useQueryClient();
+
+    return useMutation({
+        mutationFn: fetchDeleteBatchAuthor,
+        onSuccess: () => {
+            queryClient.invalidateQueries({
+                queryKey: ['authorList']
+            })
+        }
+    });
+}(No newline at end of file)
 
src/admin/feature/role/author/hook/mutation/useUpdateAuthor.ts (added)
+++ src/admin/feature/role/author/hook/mutation/useUpdateAuthor.ts
@@ -0,0 +1,15 @@
+import {useMutation, useQueryClient} from "@tanstack/react-query";
+import {fetchUpdateAuthor} from "../../api/authorApi.ts";
+
+export const useUpdateAuthor = () => {
+    const queryClient = useQueryClient();
+
+    return useMutation({
+        mutationFn: fetchUpdateAuthor,
+        onSuccess: () => {
+            queryClient.invalidateQueries({
+                queryKey: ['authorDetail']
+            })
+        }
+    });
+}(No newline at end of file)
 
src/admin/feature/role/author/hook/page/useAuthorFormPage.ts (added)
+++ src/admin/feature/role/author/hook/page/useAuthorFormPage.ts
@@ -0,0 +1,154 @@
+import type {FormActionsModel, FormMode, HeaderModel, StatusModel} from "../../../../../../type/viewModel.ts";
+import {useAuthorDetail} from "../query/useAuthorDetail.ts";
+import {type ChangeEvent, useMemo, useState} from "react";
+import {useNavigate} from "react-router-dom";
+import {ADMIN_AUTHOR_LIST_ROUTE} from "../../../../../route/adminRouteMap.ts";
+import type {AuthorFormItem} from "../../type/author.types.ts";
+import {toast} from "react-toastify";
+import {useCreateAuthor} from "../mutation/useCreateAuthor.ts";
+import {useUpdateAuthor} from "../mutation/useUpdateAuthor.ts";
+import {useDeleteAuthor} from "../mutation/useDeleteAuthor.ts";
+
+type AuthorFormPageModel = {
+    header: HeaderModel
+    status: StatusModel;
+    form: {
+        form: AuthorFormItem;
+        onChange: (event: ChangeEvent<HTMLInputElement>) => void;
+    }
+    actions: FormActionsModel<FormMode>
+}
+
+const initAuthorFormData: AuthorFormItem = {
+    authorCode: '',
+    authorNm: '',
+    authorDc: ''
+}
+
+const createInitialForm = (
+    item?: AuthorFormItem,
+) => ({
+    ...initAuthorFormData,
+    ...item
+})
+
+export const useAuthorFormPage = (authorCode: string):AuthorFormPageModel => {
+    const navigate = useNavigate();
+    const mode: FormMode = authorCode ? 'update' : 'create';
+    const [formDraft, setFormDraft] = useState({});
+    const {data, isLoading, error} = useAuthorDetail(authorCode);
+
+    const {mutateAsync: createAuthor, isPending: isCreating} = useCreateAuthor();
+    const {mutateAsync: updateAuthor, isPending: isUpdating} = useUpdateAuthor();
+    const {mutateAsync: deleteAuthor, isPending: isDeleting} = useDeleteAuthor();
+    const isPending = isCreating || isUpdating || isDeleting
+
+    const title = `권한 ${mode === 'create' ? '생성' : '수정'}`
+    const breadcrumb = [
+        {label: '권한관리'},
+        {label: '권한별룰관리', url: ADMIN_AUTHOR_LIST_ROUTE},
+        {label: title}
+    ]
+
+    const baseForm = useMemo(() => createInitialForm(data), [data]);
+
+    const form = {
+        ...baseForm,
+        ...formDraft,
+    };
+
+    const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
+        const {name, value} = event.target;
+
+        setFormDraft((prev) => ({
+            ...prev,
+            [name]: value
+        }));
+    }
+
+    const validateForm = () => {
+        if(!form.authorCode){
+            toast.warning('권한코드를 입력해주세요.');
+            return false;
+        }
+
+        if(!form.authorNm) {
+            toast.warning('권한명을 입력해주세요.');
+            return false;
+        }
+        return true;
+    }
+
+    const handleCreate = async () => {
+        if(!validateForm()) {
+            return;
+        }
+        await toast.promise(
+            createAuthor(form),
+            {
+                pending: '등록 중...',
+                success: '등록 완료',
+                error: '등록 실패'
+            }
+        )
+
+        handleList();
+    }
+
+    const handleUpdate = async () => {
+        if(!validateForm()) {
+            return;
+        }
+        await toast.promise(
+            updateAuthor(form),
+            {
+                pending: '수정 중...',
+                success: '수정 완료',
+                error: '수정 실패'
+            }
+        )
+
+        handleList();
+    }
+
+    const handleDelete = async () => {
+        await toast.promise(
+            deleteAuthor(form.authorCode),
+            {
+                pending: '삭제 중...',
+                success: '삭제 완료',
+                error: '삭제 실패'
+            }
+        )
+        handleList();
+    }
+
+    const handleList = () => {
+        navigate(ADMIN_AUTHOR_LIST_ROUTE);
+    }
+
+    return {
+        header: {
+            title,
+            breadcrumb,
+            homeUrl: '#'
+        },
+        status: {
+            isLoading,
+            error,
+            successMessage: '데이터 조회가 완료되었습니다.'
+        },
+        form: {
+            form,
+            onChange: handleChange,
+        },
+        actions: {
+            mode,
+            disabled: isPending,
+            onCreate: handleCreate,
+            onUpdate: handleUpdate,
+            onDelete: handleDelete,
+            onList: handleList,
+        }
+    }
+}(No newline at end of file)
src/admin/feature/role/author/hook/page/useAuthorListPage.ts
--- src/admin/feature/role/author/hook/page/useAuthorListPage.ts
+++ src/admin/feature/role/author/hook/page/useAuthorListPage.ts
@@ -13,6 +13,8 @@
     SearchModel,
     StatusModel,
 } from "../../../../../../type/viewModel.ts";
+import {useDeleteBatchAuthor} from "../mutation/useDeleteBatchAuthor.ts";
+import {toast} from "react-toastify";
 
 type AuthorListRowActions = {
     onDetail: (authorCode: string) => void;
@@ -59,9 +61,12 @@
         isLoading,
         error
     } = useAuthorList(searchParams);
-    const authorIds = useMemo(() => list.map((item) => item.authorNm), [list]);
+    const {mutateAsync: deleteBatchAuthor} = useDeleteBatchAuthor();
+
+    const authorIds = useMemo(() => list.map((item) => item.authorCode), [list]);
 
     const {
+        checkedIds,
         isAllChecked,
         isPartiallyChecked,
         isChecked,
@@ -79,7 +84,7 @@
     const homeUrl = '#';
 
     const handleDetail = (authorCode: string) => {
-        navigate(`${ADMIN_AUTHOR_DETAIL_ROUTE}?authorCode=${authorCode}`);
+        navigate(`${ADMIN_AUTHOR_DETAIL_ROUTE}/${authorCode}`);
     }
 
     const handleRoleMove = (authorCode: string, authorNm: string) => {
@@ -93,10 +98,27 @@
         }));
     };
 
-    const handleDelete = () => {
+    const handleDelete = async () => {
+        if(checkedIds.length === 0) {
+            toast.warning('삭제할 권한을 선택해주세요.');
+            return;
+        }
+        const authorList= checkedIds.map((authorCode) => ({
+            authorCode,
+        }));
+
+        await toast.promise(
+            deleteBatchAuthor(authorList),
+            {
+                pending: '삭제 처리중...',
+                success: '삭제 완료',
+                error: '삭제 실패'
+            }
+        )
     };
 
     const handleCreate = () => {
+        navigate(`${ADMIN_AUTHOR_DETAIL_ROUTE}`);
     };
 
     return {
 
src/admin/feature/role/author/page/AuthorFormPage.tsx (added)
+++ src/admin/feature/role/author/page/AuthorFormPage.tsx
@@ -0,0 +1,21 @@
+import {useAuthorFormPage} from "../hook/page/useAuthorFormPage.ts";
+import {PageHeader} from "../../../../component/PageHeader.tsx";
+import {ActionButtonFormGroup} from "../../../../component/button/ActionButtonFormGroup.tsx";
+import {useLoadingToast} from "../../../../hook/useLoadingToast.ts";
+import {AuthorFormTable} from "../components/AuthorFormTable.tsx";
+import {useParams} from "react-router-dom";
+
+export const AuthorFormPage = () => {
+    const {authorCode = ''} = useParams();
+    const {header, status, form, actions} = useAuthorFormPage(authorCode);
+
+    useLoadingToast(status);
+
+    return (
+        <>
+            <PageHeader {...header}/>
+            <AuthorFormTable {...form} mode={actions.mode}/>
+            <ActionButtonFormGroup {...actions}/>
+        </>
+    );
+}(No newline at end of file)
src/admin/feature/role/author/type/author.types.ts
--- src/admin/feature/role/author/type/author.types.ts
+++ src/admin/feature/role/author/type/author.types.ts
@@ -9,3 +9,13 @@
     authorDc: string;
     authorCreatDe: string;
 }
+
+export interface AuthorFormItem {
+    authorCode: string;
+    authorNm: string;
+    authorDc: string;
+}
+
+export interface DeleteBatchAuthorRequest {
+    authorCode: string;
+}
src/admin/route/AdminRoute.tsx
--- src/admin/route/AdminRoute.tsx
+++ src/admin/route/AdminRoute.tsx
@@ -4,7 +4,9 @@
     ADMIN_AUTHOR_DETAIL_ROUTE, ADMIN_AUTHOR_GROUP_LIST_ROUTE,
     ADMIN_AUTHOR_LIST_ROUTE,
     ADMIN_AUTHOR_ROLE_LIST_ROUTE, ADMIN_BBS_ARTICLE_FORM_ROUTE,
-    ADMIN_BBS_MASTER_ROUTE, ADMIN_MENU_CREATE_MANAGE_ROUTE, ADMIN_MENU_POPUP_ROUTE, ADMIN_ROLE_FORM_ROUTE,
+    ADMIN_BBS_MASTER_ROUTE, ADMIN_CONTENT_FORM_ROUTE,
+    ADMIN_CONTENT_LIST_ROUTE, ADMIN_MENU_CREATE_MANAGE_ROUTE,
+    ADMIN_MENU_CREATE_TREE_ROUTE, ADMIN_MENU_POPUP_ROUTE, ADMIN_ROLE_FORM_ROUTE,
     ADMIN_ROLE_LIST_ROUTE
 } from "./adminRouteMap.ts";
 import {BoardArticleListPage} from "../feature/board/article/page/BoardArticleListPage.tsx";
@@ -17,7 +19,9 @@
 import {AdminLayout} from "../layout/AdminLayout.tsx";
 import {AuthorGroupListPage} from "../feature/role/authorGroup/page/AuthorGroupListPage.tsx";
 import {RoleListPage} from "../feature/role/role/page/RoleListPage.tsx";
-
+import {AuthorFormPage} from "../feature/role/author/page/AuthorFormPage.tsx";
+import {ContentListPage} from "../feature/content/page/ContentListPage.tsx";
+import {ContentFormPage} from "../feature/content/page/ContentFormPage.tsx";
 const ReadyPage = () => {
     return <div>Preparing menu.</div>;
 };
@@ -28,20 +32,31 @@
             <Route path={`${ADMIN_MENU_POPUP_ROUTE}/:authorCode`} element={<AuthorRoleMenuPopupPage/>}/>
 
             <Route element={<AdminLayout/>}>
+                {/* bbs */}
                 <Route path="/" element={<Navigate to={ADMIN_BBS_MASTER_ROUTE} replace/>}/>
                 <Route path={ADMIN_BBS_MASTER_ROUTE} element={<BoardListPage/>}/>
                 <Route path={`/admin/cop/bbs/article/:bbsId`} element={<BoardArticleListPage/>}/>
                 <Route path={`${ADMIN_BBS_ARTICLE_FORM_ROUTE}:bbsId`} element={<BoardFormPage/>}/>
                 <Route path={ADMIN_BBS_ARTICLE_FORM_ROUTE} element={<BoardFormPage/>}/>
 
+                {/* author */}
                 <Route path={ADMIN_AUTHOR_LIST_ROUTE} element={<AuthorListPage/>}/>
-                <Route path={ADMIN_AUTHOR_DETAIL_ROUTE} element={<ReadyPage/>}/>
+                <Route path={ADMIN_AUTHOR_DETAIL_ROUTE} element={<AuthorFormPage/>}/>
+                <Route path={`${ADMIN_AUTHOR_DETAIL_ROUTE}/:authorCode`} element={<AuthorFormPage/>}/>
                 <Route path={ADMIN_AUTHOR_ROLE_LIST_ROUTE} element={<AuthorRoleListPage/>}/>
                 <Route path={ADMIN_MENU_CREATE_MANAGE_ROUTE} element={<AuthorRoleMenuListPage/>}/>
                 <Route path={ADMIN_AUTHOR_GROUP_LIST_ROUTE} element={<AuthorGroupListPage/>}/>
                 <Route path={ADMIN_ROLE_LIST_ROUTE} element={<RoleListPage/>}/>
                 <Route path={ADMIN_ROLE_FORM_ROUTE} element={<RoleFormPage/>}/>
                 <Route path={`${ADMIN_ROLE_FORM_ROUTE}/:roleCode`} element={<RoleFormPage/>}/>
+
+                {/* content */}
+                <Route path={ADMIN_CONTENT_LIST_ROUTE} element={<ContentListPage />} />
+                <Route path={ADMIN_CONTENT_FORM_ROUTE} element={<ContentFormPage />} />
+
+                {/* menu */}
+                <Route path={ADMIN_MENU_CREATE_TREE_ROUTE} element={<></>} />
+
                 <Route path="*" element={<ReadyPage/>}/>
             </Route>
         </Routes>
src/admin/route/adminRouteMap.ts
--- src/admin/route/adminRouteMap.ts
+++ src/admin/route/adminRouteMap.ts
@@ -13,7 +13,11 @@
 export const ADMIN_ROLE_FORM_ROUTE = `${ADMIN_ROUTE_PREFIX}/sec/rmt/detail.do`;
 
 export const ADMIN_MAIN_ZONE_LIST_ROUTE = `${ADMIN_ROUTE_PREFIX}/uss/ion/pwm/mainZoneList.do`;
+
 export const ADMIN_CONTENT_LIST_ROUTE = `${ADMIN_ROUTE_PREFIX}/uss/ion/cnt/contentList.do`;
+export const ADMIN_CONTENT_FORM_ROUTE = `${ADMIN_ROUTE_PREFIX}/uss/ion/cnt/contentForm.do`;
+
+
 export const ADMIN_MAIN_PAGE_ROUTE = `${ADMIN_ROUTE_PREFIX}/cmm/main/mainPage.do`;
 export const ADMIN_LOGIN_GROUP_POLICY_ROUTE = `${ADMIN_ROUTE_PREFIX}/uat/uap/selectLoginGroupPolicyList.do`;
 export const ADMIN_MEMBER_MANAGE_ROUTE = `${ADMIN_ROUTE_PREFIX}/uss/umt/EgovMberManage.do`;
src/styles/adm/style.css
--- src/styles/adm/style.css
+++ src/styles/adm/style.css
@@ -156,3 +156,20 @@
 .menu_list{width:100%;max-height:calc(100vh - 300px);margin:12px 0 0 0;border-radius:4px;}
 .menu_detail{width:80%;}
 
+/* rich text editor */
+.rich_text_editor{width:100%;border:1px solid var(--default-line-color);border-radius:5px;background:#fff;overflow:hidden;}
+.rich_text_editor_toolbar{display:flex;flex-wrap:wrap;gap:4px;padding:8px;border-bottom:1px solid var(--default-line-color);background:#f5f7f9;}
+.rich_text_editor_toolbar button{min-width:32px;height:30px;padding:0 8px;border:1px solid #cfd6e2;border-radius:4px;background:#fff;color:#222;font-size:13px;font-weight:700;line-height:1;cursor:pointer;}
+.rich_text_editor_toolbar button.active{border-color:var(--primary-color);background:#e9eef8;color:var(--primary-color);}
+.rich_text_editor_toolbar button:disabled{opacity:0.45;cursor:not-allowed;}
+.rich_text_editor_body{min-height:320px;padding:18px;outline:none;font-size:16px;line-height:1.7;color:var(--body-text-color);}
+.rich_text_editor_body p{margin:0 0 10px 0;}
+.rich_text_editor_body h2{margin:0 0 14px 0;font-size:24px;font-weight:700;color:var(--primary-title-color);}
+.rich_text_editor_body ul,.rich_text_editor_body ol{margin:0 0 12px 22px;}
+.rich_text_editor_body a{color:var(--primary-color);text-decoration:underline;}
+.rich_text_editor_body img{max-width:100%;height:auto;}
+.rich_text_editor_body table{width:100%;border-collapse:collapse;margin:12px 0;}
+.rich_text_editor_body th,.rich_text_editor_body td{min-width:80px;border:1px solid var(--default-line-color);padding:8px;text-align:left;vertical-align:top;}
+.rich_text_editor_body th{background:#f2f4f6;font-weight:700;}
+.rich_text_editor_body .is-empty::before{content:attr(data-placeholder);float:left;height:0;color:#9aa3b2;pointer-events:none;}
+
src/styles/usr/style.css
--- src/styles/usr/style.css
+++ src/styles/usr/style.css
@@ -139,3 +139,19 @@
 .cmmt_input textarea{width:calc(100% - 95px);height:120px;margin:0 10px 0 0;}
 .cmmt_input button.btn.xlarge{height:120px;}
 
+/* content preview */
+.content_preview{border-top:2px solid var(--primary-color);}
+.content_preview_header{padding:24px 0;border-bottom:1px solid var(--default-line-color);}
+.content_preview_header h3{font-size:28px;font-weight:700;color:var(--primary-title-color);}
+.content_preview_header p{margin:8px 0 0 0;font-size:15px;color:#666;}
+.content_preview_body{min-height:360px;padding:28px 0;font-size:17px;line-height:1.8;color:var(--body-text-color);}
+.content_preview_body h2{margin:0 0 18px 0;font-size:26px;font-weight:700;color:var(--primary-title-color);}
+.content_preview_body p{margin:0 0 12px 0;}
+.content_preview_body ul,.content_preview_body ol{margin:0 0 14px 24px;}
+.content_preview_body a{color:var(--primary-color);text-decoration:underline;}
+.content_preview_body img{max-width:100%;height:auto;}
+.content_preview_body table{width:100%;border-collapse:collapse;margin:16px 0;}
+.content_preview_body th,.content_preview_body td{border:1px solid var(--default-line-color);padding:10px;text-align:left;vertical-align:top;}
+.content_preview_body th{background:#f2f4f6;font-weight:700;}
+.content_preview_empty{padding:60px 0;text-align:center;color:#666;}
+
 
src/user/UserContentPreviewPage.tsx (added)
+++ src/user/UserContentPreviewPage.tsx
@@ -0,0 +1,43 @@
+import DOMPurify from 'dompurify';
+import {useMemo} from 'react';
+import {useSearchParams} from 'react-router-dom';
+import {useContentDetail} from '../admin/feature/content/hook/query/useContentDetail.ts';
+
+export function UserContentPreviewPage() {
+  const [params] = useSearchParams();
+  const cntId = params.get('cntId') ?? '';
+  const cntDtId = params.get('cntDtId') ?? '';
+  const {data, isLoading, error} = useContentDetail(cntId, cntDtId, {enabled: !!cntId});
+
+  const contentHtml = useMemo(
+    () => DOMPurify.sanitize(data?.cntCn ?? ''),
+    [data?.cntCn],
+  );
+
+  if (!cntId) {
+    return <div className="content_preview_empty">미리보기 콘텐츠 정보가 없습니다.</div>;
+  }
+
+  if (isLoading) {
+    return <div className="content_preview_empty">미리보기를 불러오는 중입니다.</div>;
+  }
+
+  if (error) {
+    return <div className="content_preview_empty">미리보기를 불러오지 못했습니다.</div>;
+  }
+
+  return (
+    <article className="content_preview">
+      <header className="content_preview_header">
+        <h3>{data?.cntName}</h3>
+        {data?.updtPnttm || data?.registPnttm ? (
+          <p>{data.updtPnttm || data.registPnttm}</p>
+        ) : null}
+      </header>
+      <div
+        className="content_preview_body"
+        dangerouslySetInnerHTML={{__html: contentHtml}}
+      />
+    </article>
+  );
+}
src/user/UserLayout.tsx
--- src/user/UserLayout.tsx
+++ src/user/UserLayout.tsx
@@ -109,7 +109,12 @@
   );
 }
 
-export function UserLayout({ children }: { children: ReactNode }) {
+type UserLayoutProps = {
+  children: ReactNode;
+  title?: string;
+};
+
+export function UserLayout({ children, title = '공지사항' }: UserLayoutProps) {
   return (
     <div className="wrap">
       <UserHeader />
@@ -118,7 +123,7 @@
           <UserSideMenu />
           <div className="content_wrap">
             <div className="content_title">
-              <h2>공지사항</h2>
+              <h2>{title}</h2>
             </div>
             <div className="contents">{children}</div>
           </div>
Add a comment
List