├── .gitignore ├── CNAME ├── LICENSE ├── README.md ├── _config.yml ├── _data └── navigation.yml ├── _includes └── head │ └── custom.html ├── admin ├── .eslintrc.cjs ├── .gitignore ├── .prettierrc.json ├── .vscode │ └── extensions.json ├── dist │ ├── assets │ │ ├── Autocomplete-P8uNMzjf.js │ │ ├── Checkbox-xBLiHrYK.js │ │ ├── Color-BI1EptyT.js │ │ ├── Combobox-BzsnNK90.js │ │ ├── Date-2WkyCTxd.js │ │ ├── FilesView-DBubzPcf.css │ │ ├── FilesView-DpxfpJ-I.js │ │ ├── Image-KH4HD0zm.js │ │ ├── Image-hEc3DOqi.css │ │ ├── Images-BYrGx0Vh.css │ │ ├── Images-DNp4xmDu.js │ │ ├── LoginView-CqvYop6P.css │ │ ├── LoginView-DoTNu-IV.js │ │ ├── Markdown-BbjKacP0.js │ │ ├── Markdown-DjYTd6Zz.css │ │ ├── Number-C9GxbshK.js │ │ ├── PageDetails-BczciiEp.js │ │ ├── PageDetails-D0v-PlsS.css │ │ ├── PagesView-B4WiDGRv.js │ │ ├── PagesView-C-94Jo-m.css │ │ ├── Plaintext-yL1SvkOj.js │ │ ├── Radio-DPUiKW3l.js │ │ ├── Range-rc27Zhut.js │ │ ├── Select-arf17DoR.js │ │ ├── Slider-GEMU6uF-.js │ │ ├── String-2Ct3yEMZ.js │ │ ├── Switch-CtwBo_h3.js │ │ ├── Table-Drh1Nup_.js │ │ ├── Text-TjwpRchm.js │ │ ├── bg-full-DfnNwNLA.jpg │ │ ├── ckeditor5-BlBZuSiD.css │ │ ├── ckeditor5-DOqJdLtc.js │ │ ├── index-CzuiuYVM.js │ │ ├── materialdesignicons-webfont-B7mPwVP_.ttf │ │ ├── materialdesignicons-webfont-CSr8KVlo.eot │ │ ├── materialdesignicons-webfont-Dp5v-WZN.woff2 │ │ └── materialdesignicons-webfont-PXm3-2wK.woff │ ├── favicon.ico │ ├── index.css │ └── index.js ├── index.html ├── package-lock.json ├── package.json ├── src │ ├── App.vue │ ├── assets │ │ └── base.css │ ├── components │ │ ├── AsideCount.vue │ │ ├── AsideList.vue │ │ ├── AsideMeta.vue │ │ ├── ElementDetails.vue │ │ ├── ElementDetailsElement.vue │ │ ├── ElementDetailsRefs.vue │ │ ├── ElementList.vue │ │ ├── ElementListItems.vue │ │ ├── Fields.vue │ │ ├── FileDetails.vue │ │ ├── FileDetailsFile.vue │ │ ├── FileDetailsRefs.vue │ │ ├── FileList.vue │ │ ├── FileListItems.vue │ │ ├── History.vue │ │ ├── Navigation.vue │ │ ├── PageDetails.vue │ │ ├── PageDetailsContent.vue │ │ ├── PageDetailsContentList.vue │ │ ├── PageDetailsPage.vue │ │ ├── PageDetailsPageConfig.vue │ │ ├── PageDetailsPageMeta.vue │ │ ├── PageDetailsPageProps.vue │ │ ├── PageDetailsPreview.vue │ │ ├── PageList.vue │ │ ├── PageListItems.vue │ │ ├── Schema.vue │ │ ├── SchemaItems.vue │ │ ├── User.vue │ │ └── __tests__ │ │ │ └── HelloWorld.cy.js │ ├── fields │ │ ├── Audio.vue │ │ ├── Autocomplete.vue │ │ ├── Checkbox.vue │ │ ├── Color.vue │ │ ├── Combobox.vue │ │ ├── Date.vue │ │ ├── File.vue │ │ ├── Html.vue │ │ ├── Image.vue │ │ ├── Images.vue │ │ ├── Items.vue │ │ ├── Markdown.vue │ │ ├── Number.vue │ │ ├── Plaintext.vue │ │ ├── Radio.vue │ │ ├── Range.vue │ │ ├── Select.vue │ │ ├── Slider.vue │ │ ├── String.vue │ │ ├── Switch.vue │ │ ├── Table.vue │ │ ├── Text.vue │ │ ├── Url.vue │ │ └── Video.vue │ ├── graphql.js │ ├── log.js │ ├── main.js │ ├── routes.js │ ├── stores.js │ ├── utils.js │ └── views │ │ └── LoginView.vue └── vite.config.js ├── assets ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-touch-icon.png ├── favicon-16x16.png ├── favicon-32x32.png └── laravel-cms.svg ├── composer.json ├── config └── cms.php ├── database ├── migrations │ ├── 2023_02_18_202800_create_pages_table.php │ ├── 2023_02_18_202810_create_elements_table.php │ ├── 2023_02_18_202820_create_files_table.php │ ├── 2023_02_18_202830_create_versions_table.php │ ├── 2023_02_18_202840_create_page_element_table.php │ ├── 2023_02_18_202840_create_version_element_table.php │ ├── 2023_02_18_202850_create_element_file_table.php │ ├── 2023_02_18_202850_create_page_file_table.php │ ├── 2023_02_18_202850_create_version_file_table.php │ └── 2023_02_18_202920_add_users_cmseditor.php └── seeders │ └── CmsSeeder.php ├── docs ├── graphql-authentication.md ├── graphql-datatypes.md ├── graphql-introduction.md ├── graphql-pages.md ├── jsonapi-content-only.md ├── jsonapi-introduction.md ├── jsonapi-root-megamenu.md ├── jsonapi-root-navigation.md ├── jsonapi-subpages-breadcrumb.md └── laravel-cms-erm.svg ├── favicon.ico ├── graphql └── cms.graphql ├── package-lock.json ├── phpunit.xml ├── public ├── article.css ├── blog.css ├── cms.css ├── prism.css └── prism.js ├── site.webmanifest ├── src ├── CmsServiceProvider.php ├── Commands │ ├── Install.php │ ├── Publish.php │ ├── Serve.php │ └── User.php ├── Concerns │ └── Tenancy.php ├── GraphQL │ ├── Exception.php │ ├── Mutations │ │ ├── AddElement.php │ │ ├── AddFile.php │ │ ├── AddPage.php │ │ ├── CmsLogin.php │ │ ├── CmsLogout.php │ │ ├── DropElement.php │ │ ├── DropFile.php │ │ ├── DropPage.php │ │ ├── KeepElement.php │ │ ├── KeepFile.php │ │ ├── KeepPage.php │ │ ├── MovePage.php │ │ ├── PubElement.php │ │ ├── PubFile.php │ │ ├── PubPage.php │ │ ├── PurgeElement.php │ │ ├── PurgeFile.php │ │ ├── PurgePage.php │ │ ├── SaveElement.php │ │ ├── SaveFile.php │ │ └── SavePage.php │ └── Query.php ├── Http │ └── Controllers │ │ └── PageController.php ├── JsonApi │ └── V1 │ │ ├── Controllers │ │ └── JsonapiController.php │ │ ├── Elements │ │ └── ElementSchema.php │ │ ├── Pages │ │ ├── PageCollectionQuery.php │ │ ├── PageQuery.php │ │ └── PageSchema.php │ │ └── Server.php ├── Models │ ├── Element.php │ ├── File.php │ ├── Page.php │ └── Version.php ├── Permission.php ├── Policies │ ├── ElementPolicy.php │ ├── FilePolicy.php │ └── PagePolicy.php ├── Scopes │ ├── Status.php │ ├── Tenancy.php │ └── Timeframe.php ├── Tenancy.php ├── helpers.php └── server.php ├── tests ├── CommandTest.php ├── GraphqlAuthTest.php ├── GraphqlElementTest.php ├── GraphqlFileTest.php ├── GraphqlPageTest.php ├── HelpersTest.php ├── JsonapiTest.php ├── TestAbstract.php ├── User.php └── default-schema.graphql └── views ├── admin.blade.php ├── article.blade.php ├── cards.blade.php ├── code.blade.php ├── heading.blade.php ├── html.blade.php ├── image-text.blade.php ├── image.blade.php ├── invalid.blade.php ├── page.blade.php ├── paragraph.blade.php ├── table.blade.php └── video.blade.php /.gitignore: -------------------------------------------------------------------------------- 1 | .phpunit.result.cache 2 | composer.lock 3 | vendor/ 4 | -------------------------------------------------------------------------------- /CNAME: -------------------------------------------------------------------------------- 1 | laravel-cms.org -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Aimeos 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | remote_theme: "mmistakes/minimal-mistakes@4.24.0" 2 | plugins: 3 | - jekyll-include-cache 4 | include: ["docs"] 5 | name: "Aimeos" 6 | description: "Simple like Wordpress, powerful as Contentful! The easy, flexible and scalable API-first Laravel CMS package - Open Source" 7 | title: "Laravel CMS" 8 | title_separator: "|" 9 | og_image: /assets/laravel-cms.svg 10 | comments: 11 | provider: "disqus" 12 | disqus: 13 | shortname: "laravel-cms-org" 14 | lunr: 15 | search_within_pages: true 16 | defaults: 17 | - scope: 18 | path: "" 19 | type: pages 20 | values: 21 | classes: wide 22 | layout: single 23 | read_time: false 24 | author_profile: false 25 | share: false 26 | comments: true 27 | sidebar: 28 | nav: "docs" 29 | -------------------------------------------------------------------------------- /_data/navigation.yml: -------------------------------------------------------------------------------- 1 | main: 2 | - title: "Frontend API" 3 | url: /jsonapi/introduction/ 4 | - title: "Backend API" 5 | url: /graphql/introduction/ 6 | - title: "Github" 7 | url: https://github.com/aimeos/laravel-cms 8 | 9 | docs: 10 | - title: "Frontend JSON:API" 11 | children: 12 | - title: "Introduction" 13 | url: /jsonapi/introduction/ 14 | - title: "Root page with navigation" 15 | url: /jsonapi/root-navigation/ 16 | - title: "Root page with mega-menu" 17 | url: /jsonapi/root-megamenu/ 18 | - title: "Sub-pages with breadcrumb" 19 | url: /jsonapi/subpages-breadcrumb/ 20 | 21 | - title: "Backend GraphQL API" 22 | children: 23 | - title: "Introduction" 24 | url: /graphql/introduction/ 25 | - title: "Data types" 26 | url: /graphql/data-types/ 27 | - title: "Authentication" 28 | url: /graphql/authentication/ 29 | - title: "Managing pages" 30 | url: /graphql/pages/ 31 | -------------------------------------------------------------------------------- /_includes/head/custom.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /admin/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | require('@rushstack/eslint-patch/modern-module-resolution') 3 | 4 | module.exports = { 5 | root: true, 6 | 'extends': [ 7 | 'plugin:vue/vue3-essential', 8 | 'eslint:recommended', 9 | '@vue/eslint-config-prettier/skip-formatting' 10 | ], 11 | overrides: [ 12 | { 13 | files: [ 14 | '**/__tests__/*.{cy,spec}.{js,ts,jsx,tsx}', 15 | 'cypress/e2e/**/*.{cy,spec}.{js,ts,jsx,tsx}' 16 | ], 17 | 'extends': [ 18 | 'plugin:cypress/recommended' 19 | ] 20 | } 21 | ], 22 | parserOptions: { 23 | ecmaVersion: 'latest' 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /admin/.gitignore: -------------------------------------------------------------------------------- 1 | dist/index.html 2 | 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | pnpm-debug.log* 10 | lerna-debug.log* 11 | 12 | node_modules 13 | .DS_Store 14 | dist-ssr 15 | coverage 16 | *.local 17 | 18 | /cypress/videos/ 19 | /cypress/screenshots/ 20 | 21 | # Editor directories and files 22 | .vscode/* 23 | !.vscode/extensions.json 24 | .idea 25 | *.suo 26 | *.ntvs* 27 | *.njsproj 28 | *.sln 29 | *.sw? 30 | -------------------------------------------------------------------------------- /admin/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/prettierrc", 3 | "semi": false, 4 | "tabWidth": 2, 5 | "singleQuote": true, 6 | "printWidth": 100, 7 | "trailingComma": "none" 8 | } -------------------------------------------------------------------------------- /admin/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"] 3 | } 4 | -------------------------------------------------------------------------------- /admin/dist/assets/Autocomplete-P8uNMzjf.js: -------------------------------------------------------------------------------- 1 | import{_ as a,c as n,V as d,o as m}from"../index.js";const u={props:["modelValue","config"],emits:["update:modelValue"]};function s(l,o,e,i,c,r){return m(),n(d,{items:e.config.options||[],label:e.config.label||"",modelValue:e.modelValue,"onUpdate:modelValue":o[0]||(o[0]=t=>l.$emit("update:modelValue",t)),density:"comfortable",variant:"underlined"},null,8,["items","label","modelValue"])}const p=a(u,[["render",s]]);export{p as default}; 2 | -------------------------------------------------------------------------------- /admin/dist/assets/Checkbox-xBLiHrYK.js: -------------------------------------------------------------------------------- 1 | import{_ as t,c as d,a as n,o as u}from"../index.js";const s={props:["modelValue","config"],emits:["update:modelValue"]};function c(o,e,l,m,r,p){return u(),d(n,{label:l.config.label||"",modelValue:l.modelValue,"onUpdate:modelValue":e[0]||(e[0]=a=>o.$emit("update:modelValue",a))},null,8,["label","modelValue"])}const i=t(s,[["render",c]]);export{i as default}; 2 | -------------------------------------------------------------------------------- /admin/dist/assets/Color-BI1EptyT.js: -------------------------------------------------------------------------------- 1 | import{_ as t,c as r,b as d,o as s}from"../index.js";const u={props:["modelValue","config"],emits:["update:modelValue"]};function n(o,e,l,m,p,c){return s(),r(d,{modelValue:l.modelValue,"onUpdate:modelValue":e[0]||(e[0]=a=>o.$emit("update:modelValue",a))},null,8,["modelValue"])}const V=t(u,[["render",n]]);export{V as default}; 2 | -------------------------------------------------------------------------------- /admin/dist/assets/Combobox-BzsnNK90.js: -------------------------------------------------------------------------------- 1 | import{_ as t,c as i,d as n,o as m}from"../index.js";const u={props:["modelValue","config"],emits:["update:modelValue"]};function d(o,l,e,c,s,f){return m(),i(n,{items:e.config.options||[],label:e.config.label||"",multiple:e.config.multiple,chips:e.config.multiple,modelValue:e.modelValue,"onUpdate:modelValue":l[0]||(l[0]=a=>o.$emit("update:modelValue",a)),density:"comfortable",variant:"underlined",clearable:""},null,8,["items","label","multiple","chips","modelValue"])}const p=t(u,[["render",d]]);export{p as default}; 2 | -------------------------------------------------------------------------------- /admin/dist/assets/Date-2WkyCTxd.js: -------------------------------------------------------------------------------- 1 | import{_ as l,c as s,e as d,o as n}from"../index.js";const u={props:["modelValue","config"],emits:["update:modelValue"]};function m(o,e,a,r,c,p){return n(),s(d,{modelValue:a.modelValue,"onUpdate:modelValue":e[0]||(e[0]=t=>o.$emit("update:modelValue",t)),"show-adjacent-months":""},null,8,["modelValue"])}const V=l(u,[["render",m]]);export{V as default}; 2 | -------------------------------------------------------------------------------- /admin/dist/assets/FilesView-DBubzPcf.css: -------------------------------------------------------------------------------- 1 | nav.v-navigation-drawer{background-color:#ebf4ff}.v-sheet{padding:1rem 1%} 2 | -------------------------------------------------------------------------------- /admin/dist/assets/FilesView-DpxfpJ-I.js: -------------------------------------------------------------------------------- 1 | import{b as m,P as u,N as f}from"./PageDetails-BczciiEp.js";import{_ as v,c as g,B as o,am as V,C as B,o as N,h as n,aa as $,ac as C,a1 as t,a6 as l,U as i,j as r,a2 as d}from"../index.js";import"./index-CzuiuYVM.js";const U={components:{Navigation:f,PageDetails:u},data:()=>({nav:null,aside:null,bgUrl:m})};function b(a,e,k,w,y,c){const p=B("Navigation");return N(),g(V,null,{default:o(()=>[n($,{elevation:2,density:"compact",image:a.bgUrl},{prepend:o(()=>[n(l,{onClick:e[0]||(e[0]=i(s=>a.nav=!a.nav,["stop"]))},{default:o(()=>[n(r,{size:"x-large"},{default:o(()=>[t(d(a.nav?"mdi-close":"mdi-menu"),1)]),_:1})]),_:1})]),default:o(()=>[n(C,null,{default:o(()=>e[3]||(e[3]=[t("Files")])),_:1}),n(l,{onClick:e[1]||(e[1]=i(s=>a.aside=!a.aside,["stop"]))},{default:o(()=>[n(r,{size:"x-large"},{default:o(()=>[t(d(a.aside?"mdi-chevron-right":"mdi-chevron-left"),1)]),_:1})]),_:1})]),_:1},8,["image"]),n(p,{modelValue:a.nav,"onUpdate:modelValue":e[2]||(e[2]=s=>a.nav=s)},null,8,["modelValue"])]),_:1})}const F=v(U,[["render",b]]);export{F as default}; 2 | -------------------------------------------------------------------------------- /admin/dist/assets/Image-KH4HD0zm.js: -------------------------------------------------------------------------------- 1 | import{g as n}from"./index-CzuiuYVM.js";import{_ as f,f as s,c as h,g as u,h as m,i as p,j as g,k as c,u as V,o,l as _}from"../index.js";const v={props:["modelValue","config"],emits:["update:modelValue"],setup(){return{app:V()}},data(){return{index:Math.floor(Math.random()*1e5)}},methods:{add(t){const e=t.target.files||t.dataTransfer.files||[];e.length&&(this.$emit("update:modelValue",{path:URL.createObjectURL(e[0]),uploading:!0}),this.$apollo.mutate({mutation:n`mutation($file: Upload!) { 2 | addFile(file: $file) { 3 | id 4 | mime 5 | name 6 | path 7 | previews 8 | } 9 | }`,variables:{file:e[0]},context:{hasUpload:!0}}).then(a=>{var i;if(a.errors)throw a.errors;const r=((i=a.data)==null?void 0:i.addFile)||{};r.previews=JSON.parse(r.previews)||{},URL.revokeObjectURL(this.modelValue.path),this.$emit("update:modelValue",r)}).catch(a=>{console.error("addFile()",a)}))},remove(){var t;(t=this.modelValue)!=null&&t.id&&this.$apollo.mutate({mutation:n`mutation($id: ID!) { 10 | dropFile(id: $id) { 11 | id 12 | } 13 | }`,variables:{id:this.modelValue.id}}).then(e=>{if(e.errors)throw e.errors;this.$emit("update:modelValue",null)}).catch(e=>{console.error(`dropFile(${code})`,e)})},srcset(t){let e=[];for(const a in t)e.push(`${this.url(t[a])} ${a}w`);return e.join(", ")},url(t){return t.startsWith("http")||t.startsWith("blob:")?t:this.app.urlfile.replace(/\/+$/g,"")+"/"+t}}},k={key:0,class:"image"},b={key:1,class:"image file-input"},y=["id"],w=["for"];function x(t,e,a,r,i,l){return a.modelValue?(o(),s("div",k,[a.modelValue.uploading?(o(),h(_,{key:0,color:"primary",height:"5",indeterminate:"",rounded:""})):u("",!0),m(p,{draggable:!1,src:l.url(a.modelValue.path),srcset:l.srcset(a.modelValue.previews)},null,8,["src","srcset"]),a.modelValue.id?(o(),s("button",{key:1,onClick:e[0]||(e[0]=d=>l.remove()),title:"Remove image",type:"button"},[m(g,{icon:"mdi-trash-can",role:"img"})])):u("",!0)])):(o(),s("div",b,[c("input",{type:"file",onInput:e[1]||(e[1]=d=>l.add(d)),id:"image-"+i.index,value:null,accept:"image/*",hidden:""},null,40,y),c("label",{for:"image-"+i.index},"Add file",8,w)]))}const U=f(v,[["render",x],["__scopeId","data-v-f7a48c39"]]);export{U as default}; 14 | -------------------------------------------------------------------------------- /admin/dist/assets/Image-hEc3DOqi.css: -------------------------------------------------------------------------------- 1 | .image[data-v-f7a48c39],.file-input[data-v-f7a48c39]{display:inline-flex;border:1px solid #808080;border-radius:.5rem;position:relative;height:178px;width:178px;margin:1px}.file-input label[data-v-f7a48c39]{display:flex;flex-wrap:wrap;align-content:center;justify-content:center;height:176px;width:176px}.image button[data-v-f7a48c39]{position:absolute;background-color:rgba(var(--v-theme-primary),.75);border-radius:.5rem;padding:.75rem;color:#fff;right:0;top:0}.v-progress-linear[data-v-f7a48c39]{position:absolute;z-index:1} 2 | -------------------------------------------------------------------------------- /admin/dist/assets/Images-BYrGx0Vh.css: -------------------------------------------------------------------------------- 1 | .images[data-v-ebd9caae],.images .sortable[data-v-ebd9caae]{display:flex;flex-wrap:wrap}.image[data-v-ebd9caae],.file-input[data-v-ebd9caae]{display:inline-flex;border:1px solid #808080;border-radius:.5rem;position:relative;height:178px;width:178px;margin:1px}.file-input label[data-v-ebd9caae]{display:flex;flex-wrap:wrap;align-content:center;justify-content:center;height:176px;width:176px}.image button[data-v-ebd9caae]{position:absolute;background-color:rgba(var(--v-theme-primary),.75);border-radius:.5rem;padding:.75rem;color:#fff;right:0;top:0}.v-progress-linear[data-v-ebd9caae]{position:absolute;z-index:1} 2 | -------------------------------------------------------------------------------- /admin/dist/assets/LoginView-CqvYop6P.css: -------------------------------------------------------------------------------- 1 | body{background-image:url(./bg-full-DfnNwNLA.jpg);background-size:cover}#app{display:flex;align-items:center;justify-content:center;height:100vh}.login .v-card{width:20rem;padding:1rem;background-color:#10446b;color:#fff;opacity:0}.login.show .v-card{opacity:1;transition:opacity .5s}.login .v-card-actions{justify-content:center}.login .v-theme--light,.login .v-field--error,.login .v-field--error:not(.v-field--disabled) .v-field__clearable>.v-icon{--v-theme-error: 255,167,38}.login .error{animation:shake .82s cubic-bezier(.36,.07,.19,.97) both;transform:translateZ(0)}@keyframes shake{10%,90%{transform:translate3d(-1px,0,0)}20%,80%{transform:translate3d(2px,0,0)}30%,50%,70%{transform:translate3d(-4px,0,0)}40%,60%{transform:translate3d(4px,0,0)}} 2 | -------------------------------------------------------------------------------- /admin/dist/assets/LoginView-DoTNu-IV.js: -------------------------------------------------------------------------------- 1 | import{g as i}from"./index-CzuiuYVM.js";import{_ as m,c as u,B as r,U as p,W as t,X as f,Y as g,u as w,o as h,h as o,Z as V,$ as v,a0 as $,M as d,j as y,a1 as l,a2 as b,a3 as C,a4 as S,a5 as L,a6 as c}from"../index.js";const q={setup(){return{app:w()}},apollo:{me:{query:i`query{ 2 | me { 3 | cmseditor 4 | name 5 | } 6 | }`,error(e){console.dir(e),this.message=e.message,this.failure=!0},update(e){this.handle(e.me)}}},data:()=>({creds:{email:"",password:""},form:null,error:null,loading:!1,login:!1,show:!1}),methods:{cmslogin(){if(!this.creds.email||!this.creds.password)return!1;this.error=null,this.loading=!0,this.$apollo.mutate({mutation:i`mutation ($email: String!, $password: String!) { 7 | cmsLogin(email: $email, password: $password) { 8 | cmseditor 9 | name 10 | } 11 | }`,variables:{email:this.creds.email,password:this.creds.password}}).then(e=>{this.handle(e.data.cmsLogin||null)}).catch(e=>{console.error(`cmsLogin(email: ${this.creds.email})`,e),this.error=e.message}).finally(()=>{this.loading=!1})},handle(e){e?e.name?e.cmseditor?(this.app.me=e.name,this.error=null,g.push("/pages")):this.error="Not a CMS editor":this.error=error.message:this.login=!0}}};function k(e,s,B,F,M,n){return h(),u(f,{class:t(["login",{show:e.login}]),modelValue:e.form,"onUpdate:modelValue":s[4]||(s[4]=a=>e.form=a),onSubmit:s[5]||(s[5]=p(a=>n.cmslogin(),["prevent"]))},{default:r(()=>[o(V,{loading:e.loading,class:t(["elevation-2",{error:e.error}])},{title:r(()=>s[6]||(s[6]=[l(" Login ")])),default:r(()=>[o(v,null,{default:r(()=>[o(d,{modelValue:e.creds.email,"onUpdate:modelValue":s[0]||(s[0]=a=>e.creds.email=a),label:"E-Mail",variant:"underlined","validate-on":"blur",rules:[a=>!!a||"Field is required",a=>!!a.match(/.+@.+/)||"Invalid e-mail address"],autocomplete:"username",autofocus:""},null,8,["modelValue","rules"]),o(d,{modelValue:e.creds.password,"onUpdate:modelValue":s[3]||(s[3]=a=>e.creds.password=a),type:e.show?"text":"password",label:"Password",variant:"underlined",rules:[a=>!!a||"Field is required"],autocomplete:"current-password"},{"append-inner":r(()=>[o(y,{onClick:s[1]||(s[1]=a=>e.show=!e.show),onKeydown:s[2]||(s[2]=a=>[13,32].includes(a.keyCode)?e.show=!e.show:!1)},{default:r(()=>[l(b(e.show?"mdi-eye-off":"mdi-eye"),1)]),_:1})]),_:1},8,["modelValue","type","rules"]),$(o(C,{color:"error",icon:"mdi-alert-octagon",text:"Error: "+e.error},null,8,["text"]),[[S,e.error]])]),_:1}),o(L,null,{default:r(()=>[o(c,{type:"submit",variant:"outlined",disabled:e.form!=!0},{default:r(()=>s[7]||(s[7]=[l("Login")])),_:1},8,["disabled"])]),_:1})]),_:1},8,["loading","class"])]),_:1},8,["class","modelValue"])}const N=m(q,[["render",k]]);export{N as default}; 12 | -------------------------------------------------------------------------------- /admin/dist/assets/Markdown-DjYTd6Zz.css: -------------------------------------------------------------------------------- 1 | .ck-editor__editable_inline{min-height:200px} 2 | -------------------------------------------------------------------------------- /admin/dist/assets/Number-C9GxbshK.js: -------------------------------------------------------------------------------- 1 | import{_ as t,c as n,E as u,o as d}from"../index.js";const m={props:["modelValue","config"],emits:["update:modelValue"]};function r(o,e,l,s,c,i){return d(),n(u,{label:l.config.label||"",modelValue:l.modelValue,"onUpdate:modelValue":e[0]||(e[0]=a=>o.$emit("update:modelValue",a)),density:"comfortable",variant:"outlined"},null,8,["label","modelValue"])}const f=t(m,[["render",r]]);export{f as default}; 2 | -------------------------------------------------------------------------------- /admin/dist/assets/PageDetails-D0v-PlsS.css: -------------------------------------------------------------------------------- 1 | .v-navigation-drawer[data-v-ab757f2b]{z-index:0!important}.v-navigation-drawer--right .v-expansion-panel--active>.v-expansion-panel-title{min-height:unset}.v-navigation-drawer--right .v-expansion-panel{background-color:inherit}.v-navigation-drawer--right .v-list-item__content{color:#d0d0d0}.v-navigation-drawer--right .v-list-item--active .v-list-item__content{color:#fff}.v-navigation-drawer--right .v-list-item__overlay{opacity:0}.v-expansion-panel:nth-of-type(odd) .v-expansion-panel-title[data-v-9a99315b]{background-color:#ebf4ff}.icon[data-v-64544cac]{width:3rem}.v-timeline--vertical.v-timeline.v-timeline--side-end .v-timeline-item .v-timeline-item__body[data-v-a249e51e]{padding-inline-start:2.5%}.v-dialog .v-overlay__content>.v-card>.v-card-item+.v-card-text[data-v-a249e51e]{padding:1rem 2.5%}.v-timeline--vertical.v-timeline.v-timeline--side-end .v-timeline-item .v-timeline-item__opposite[data-v-a249e51e]{display:none}.v-timeline--vertical.v-timeline--justify-auto[data-v-a249e51e]{grid-template-columns:0 min-content auto}.v-card-text[data-v-a249e51e]{white-space:pre}.added[data-v-a249e51e]{background-color:#00ff0030}.removed[data-v-a249e51e]{background-color:#ff000030}.v-expansion-panel:nth-of-type(odd) .v-expansion-panel-title[data-v-8a7dcee5]{background-color:#ebf4ff}.btn-group[data-v-8a7dcee5]{padding:1rem 0}.header[data-v-8a7dcee5]{display:flex;justify-content:end}.subtabs[data-v-85522f67]{margin-bottom:2rem}.btn-group[data-v-85522f67]{padding:1rem 0}div.v-expansion-panel:nth-of-type(odd) .v-expansion-panel-title[data-v-361f44ef]{background-color:#40749b10}.v-expansion-panel.v-expansion-panel--active .v-expansion-panel-title[data-v-361f44ef]{background-color:#40749b!important;color:#fff}.v-expansion-panel-title[data-v-361f44ef]{padding:8px 16px}.v-expansion-panel-title .v-selection-control[data-v-361f44ef]{flex:none}.v-expansion-panel-title .content-type[data-v-361f44ef]{min-width:10rem}.v-expansion-panel-title .content-title[data-v-361f44ef]{font-weight:700}.v-expansion-panel:nth-of-type(odd) .v-expansion-panel-title[data-v-42ea131b]{background-color:#fafafa!important}.header[data-v-42ea131b]{display:flex;align-items:center;justify-content:space-between;padding:0 1rem;margin:0 .5rem 1rem}.bulk[data-v-42ea131b]{display:flex;align-items:center}.v-expansion-panel[data-v-42ea131b]{border-inline-start:3px solid inherit!important;border-radius:0}.v-expansion-panel-title__overlay[data-v-42ea131b]{background-color:transparent!important}.v-expansion-panel--active[data-v-42ea131b]{border-color:navy!important}.panel-heading[data-v-42ea131b]{overflow:hidden}.subtext[data-v-42ea131b]{display:block;overflow:hidden;max-width:25rem;max-height:1.75rem;padding-top:.25rem;text-overflow:ellipsis;white-space:nowrap;color:gray;font-size:80%}.btn-group[data-v-42ea131b]{padding:1rem 0}.v-input.search[data-v-42ea131b]{max-width:30rem}iframe{width:100%;padding:0;margin:0;border:none;min-height:calc(100vh - 6.5rem)}.v-app-bar-title[data-v-cc5223bf]{cursor:pointer}.v-toolbar__content>.v-toolbar-title[data-v-cc5223bf]{margin-inline-start:0}.v-toolbar-title__placeholder[data-v-cc5223bf]{text-align:center}.v-badge--dot .v-badge__badge[data-v-cc5223bf]{margin-inline-start:.5rem} 2 | -------------------------------------------------------------------------------- /admin/dist/assets/PagesView-C-94Jo-m.css: -------------------------------------------------------------------------------- 1 | .header{display:flex;flex-wrap:wrap;align-items:flex-end;justify-content:space-between;margin-bottom:1rem}.v-input.search{width:100%;order:3}.draft{background-color:#ffe0c0;border-radius:50%}.drag-placeholder{height:48px}.drag-placeholder-wrapper .tree-node-inner{background-color:#fafafa}.tree-node-inner{display:flex;align-items:center;border-bottom:1px solid #103050;padding:.5rem 0}.node-content,.node-url{cursor:pointer}.node-content{justify-content:space-between;margin:0 1%;width:100%}.node-text,.node-url{text-overflow:ellipsis;overflow:hidden}.node-url,.page-title{font-size:80%;color:gray}.node-url{text-align:end;align-self:end}.page-time{vertical-align:text-top}.page-domain{color:initial}.page-name,.page-title,.page-domain{display:block}.page-title{height:2.5rem}.page-domain,.page-to{white-space:nowrap;text-overflow:ellipsis;overflow:hidden}.status-disabled .page-name{text-decoration:line-through}.status-hidden .node-text{color:gray}.trashed{text-decoration:line-through}.loading{animation:blink 1s;animation-direction:alternate;animation-iteration-count:infinite}@keyframes blink{0%{opacity:0}to{opacity:1}}.load{animation:rotate 2s;animation-iteration-count:infinite}@keyframes rotate{to{transform:rotate(360deg)}}@media (min-width: 500px){.v-input.search{max-width:30rem;margin:0 1rem;width:unset;order:unset}.tree-node-inner{padding:.25rem 0}.node-content{display:flex}.node-text,.node-url{max-width:50%}}@media (min-width: 960px){.page-title{height:1.25rem}}.page-tree,.page-details{position:absolute;min-height:100vh;right:0;left:0;top:0} 2 | -------------------------------------------------------------------------------- /admin/dist/assets/Plaintext-yL1SvkOj.js: -------------------------------------------------------------------------------- 1 | import{_ as t,c as n,G as d,o as r}from"../index.js";const u={props:["modelValue","config"],emits:["update:modelValue"]};function s(l,e,a,m,c,i){return r(),n(d,{label:a.config.label||"",modelValue:a.modelValue,"onUpdate:modelValue":e[0]||(e[0]=o=>l.$emit("update:modelValue",o)),density:"comfortable",variant:"underlined",clearable:""},null,8,["label","modelValue"])}const p=t(u,[["render",s]]);export{p as default}; 2 | -------------------------------------------------------------------------------- /admin/dist/assets/Radio-DPUiKW3l.js: -------------------------------------------------------------------------------- 1 | import{_ as s,c as t,H as d,o as a,B as r,f as i,F as m,D as c,I as p}from"../index.js";const f={props:["modelValue","config"],emits:["update:modelValue"]};function V(u,l,o,_,B,g){return a(),t(d,{modelValue:o.modelValue,"onUpdate:modelValue":l[0]||(l[0]=e=>u.$emit("update:modelValue",e))},{default:r(()=>[(a(!0),i(m,null,c(o.config.options||[],(e,n)=>(a(),t(p,{label:e,value:n},null,8,["label","value"]))),256))]),_:1},8,["modelValue"])}const x=s(f,[["render",V]]);export{x as default}; 2 | -------------------------------------------------------------------------------- /admin/dist/assets/Range-rc27Zhut.js: -------------------------------------------------------------------------------- 1 | import{_ as t,c as d,J as n,o as s}from"../index.js";const u={props:["modelValue","config"],emits:["update:modelValue"]};function r(o,e,a,m,p,c){return s(),d(n,{modelValue:a.modelValue,"onUpdate:modelValue":e[0]||(e[0]=l=>o.$emit("update:modelValue",l))},null,8,["modelValue"])}const V=t(u,[["render",r]]);export{V as default}; 2 | -------------------------------------------------------------------------------- /admin/dist/assets/Select-arf17DoR.js: -------------------------------------------------------------------------------- 1 | import{_ as a,c as i,K as n,o as m}from"../index.js";const u={props:["modelValue","config"],emits:["update:modelValue"]};function c(t,l,e,d,s,f){return m(),i(n,{items:e.config.options||[],label:e.config.label||"",multiple:e.config.multiple,chips:e.config.multiple,modelValue:e.modelValue,"onUpdate:modelValue":l[0]||(l[0]=o=>t.$emit("update:modelValue",o)),density:"comfortable",variant:"underlined","item-title":"label"},null,8,["items","label","multiple","chips","modelValue"])}const p=a(u,[["render",c]]);export{p as default}; 2 | -------------------------------------------------------------------------------- /admin/dist/assets/Slider-GEMU6uF-.js: -------------------------------------------------------------------------------- 1 | import{_ as t,c as d,L as r,o as s}from"../index.js";const u={props:["modelValue","config"],emits:["update:modelValue"]};function n(o,e,l,m,p,c){return s(),d(r,{modelValue:l.modelValue,"onUpdate:modelValue":e[0]||(e[0]=a=>o.$emit("update:modelValue",a))},null,8,["modelValue"])}const V=t(u,[["render",n]]);export{V as default}; 2 | -------------------------------------------------------------------------------- /admin/dist/assets/String-2Ct3yEMZ.js: -------------------------------------------------------------------------------- 1 | import{_ as t,c as n,M as d,o as r}from"../index.js";const u={props:["modelValue","config"],emits:["update:modelValue"]};function s(a,e,l,i,m,c){return r(),n(d,{label:l.config.label||"",modelValue:l.modelValue,"onUpdate:modelValue":e[0]||(e[0]=o=>a.$emit("update:modelValue",o)),density:"comfortable",variant:"underlined",clearable:""},null,8,["label","modelValue"])}const p=t(u,[["render",s]]);export{p as default}; 2 | -------------------------------------------------------------------------------- /admin/dist/assets/Switch-CtwBo_h3.js: -------------------------------------------------------------------------------- 1 | import{_ as t,c as n,N as d,o as s}from"../index.js";const u={props:["modelValue","config"],emits:["update:modelValue"]};function c(o,e,l,m,i,r){return s(),n(d,{label:l.config.label||"",modelValue:l.modelValue,"onUpdate:modelValue":e[0]||(e[0]=a=>o.$emit("update:modelValue",a)),inset:""},null,8,["label","modelValue"])}const f=t(u,[["render",c]]);export{f as default}; 2 | -------------------------------------------------------------------------------- /admin/dist/assets/Table-Drh1Nup_.js: -------------------------------------------------------------------------------- 1 | import{_ as t,c as r,g as d,G as n,o as u}from"../index.js";const s={props:["modelValue","config"],emits:["update:modelValue"]};function m(e,a,l,p,c,i){return e.field.type==="table"?(u(),r(n,{key:0,placeholder:`val;val;val 2 | val;val;val`,"auto-grow":!0,modelValue:l.modelValue,"onUpdate:modelValue":a[0]||(a[0]=o=>e.$emit("update:modelValue",o)),variant:"underlined",density:"comfortable",clearable:""},null,8,["modelValue"])):d("",!0)}const V=t(s,[["render",m]]);export{V as default}; 3 | -------------------------------------------------------------------------------- /admin/dist/assets/Text-TjwpRchm.js: -------------------------------------------------------------------------------- 1 | import{_ as l,m as i,n as d,o as c,F as m,r as u,s as p,R as f,l as k,t as _,I as g,u as V,v as C,A as F,L as h,x as v}from"./ckeditor5-DOqJdLtc.js";import{_ as x,c as L,C as b,o as A}from"../index.js";const B={components:{Ckeditor:l},props:["modelValue","config"],emits:["update:modelValue"],data(){return{editor:v}},computed:{ckconfig(){return{licenseKey:"GPL",plugins:[i,d,c,m,u,p,f,k,_,g,V,C,F,h],toolbar:["undo","redo","removeFormat","|","bold","italic","strikethrough","code","link","|","fullscreen"]}}}};function P(o,e,a,R,t,s){const r=b("ckeditor");return A(),L(r,{config:s.ckconfig,editor:t.editor,modelValue:a.modelValue,"onUpdate:modelValue":e[0]||(e[0]=n=>o.$emit("update:modelValue",n))},null,8,["config","editor","modelValue"])}const I=x(B,[["render",P]]);export{I as default}; 2 | -------------------------------------------------------------------------------- /admin/dist/assets/bg-full-DfnNwNLA.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aimeos/laravel-cms/b449f93e5b9e370dc0e521e6d2cd5edfa5bfbcf7/admin/dist/assets/bg-full-DfnNwNLA.jpg -------------------------------------------------------------------------------- /admin/dist/assets/materialdesignicons-webfont-B7mPwVP_.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aimeos/laravel-cms/b449f93e5b9e370dc0e521e6d2cd5edfa5bfbcf7/admin/dist/assets/materialdesignicons-webfont-B7mPwVP_.ttf -------------------------------------------------------------------------------- /admin/dist/assets/materialdesignicons-webfont-CSr8KVlo.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aimeos/laravel-cms/b449f93e5b9e370dc0e521e6d2cd5edfa5bfbcf7/admin/dist/assets/materialdesignicons-webfont-CSr8KVlo.eot -------------------------------------------------------------------------------- /admin/dist/assets/materialdesignicons-webfont-Dp5v-WZN.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aimeos/laravel-cms/b449f93e5b9e370dc0e521e6d2cd5edfa5bfbcf7/admin/dist/assets/materialdesignicons-webfont-Dp5v-WZN.woff2 -------------------------------------------------------------------------------- /admin/dist/assets/materialdesignicons-webfont-PXm3-2wK.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aimeos/laravel-cms/b449f93e5b9e370dc0e521e6d2cd5edfa5bfbcf7/admin/dist/assets/materialdesignicons-webfont-PXm3-2wK.woff -------------------------------------------------------------------------------- /admin/dist/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aimeos/laravel-cms/b449f93e5b9e370dc0e521e6d2cd5edfa5bfbcf7/admin/dist/favicon.ico -------------------------------------------------------------------------------- /admin/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Laravel CMS test environment 8 | 9 | 10 |
40 |
41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /admin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "admin", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "description": "Laravel CMS admin", 7 | "keywords": [ 8 | "laravel", 9 | "cms", 10 | "vue" 11 | ], 12 | "scripts": { 13 | "dev": "vite", 14 | "build": "vite build", 15 | "preview": "vite preview", 16 | "test:e2e": "start-server-and-test preview http://localhost:4173 'cypress run --e2e'", 17 | "test:e2e:dev": "start-server-and-test 'vite dev --port 4173' http://localhost:4173 'cypress open --e2e'", 18 | "test:unit": "cypress run --component", 19 | "test:unit:dev": "cypress open --component", 20 | "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore", 21 | "format": "prettier --write src/" 22 | }, 23 | "dependencies": { 24 | "@ckeditor/ckeditor5-vue": "^7.3.0", 25 | "@he-tree/vue": "^2.9", 26 | "@vue/apollo-option": "^4.2", 27 | "apollo-link-batch-http": "^1.2", 28 | "apollo-upload-client": "^18.0.1", 29 | "ckeditor5": "^45.0.0", 30 | "diff": "^7.0", 31 | "pinia": "^3.0", 32 | "sass": "^1.86", 33 | "vite-plugin-vuetify": "^2.1", 34 | "vue": "^3.5", 35 | "vue-draggable-plus": "^0.6.0", 36 | "vue-router": "^4.5", 37 | "vue3-observe-visibility": "^1.0.3", 38 | "vuedraggable": "^4.1.0", 39 | "vuetify": "^3.8" 40 | }, 41 | "devDependencies": { 42 | "@mdi/font": "^7.4", 43 | "@rushstack/eslint-patch": "^1.11", 44 | "@vitejs/plugin-vue": "^5.2", 45 | "@vue/eslint-config-prettier": "^10.2.0", 46 | "cypress": "^14.2", 47 | "eslint": "^9.0", 48 | "eslint-plugin-cypress": "^4.2", 49 | "eslint-plugin-vue": "^10.0", 50 | "prettier": "^3.5", 51 | "start-server-and-test": "^2.0", 52 | "vite": "^6.3" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /admin/src/components/AsideCount.vue: -------------------------------------------------------------------------------- 1 | 63 | 64 | 85 | 86 | 108 | -------------------------------------------------------------------------------- /admin/src/components/AsideList.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 30 | 31 | 42 | -------------------------------------------------------------------------------- /admin/src/components/AsideMeta.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 53 | 54 | 73 | -------------------------------------------------------------------------------- /admin/src/components/ElementDetailsElement.vue: -------------------------------------------------------------------------------- 1 | 66 | 67 | 111 | 112 | -------------------------------------------------------------------------------- /admin/src/components/ElementDetailsRefs.vue: -------------------------------------------------------------------------------- 1 | 76 | 77 | 130 | 131 | -------------------------------------------------------------------------------- /admin/src/components/ElementList.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 75 | 76 | 78 | -------------------------------------------------------------------------------- /admin/src/components/Fields.vue: -------------------------------------------------------------------------------- 1 | 71 | 72 | 88 | 89 | -------------------------------------------------------------------------------- /admin/src/components/FileList.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 74 | 75 | 77 | -------------------------------------------------------------------------------- /admin/src/components/Navigation.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 29 | 30 | 37 | -------------------------------------------------------------------------------- /admin/src/components/PageDetailsContent.vue: -------------------------------------------------------------------------------- 1 | 102 | 103 | 147 | 148 | 158 | -------------------------------------------------------------------------------- /admin/src/components/PageDetailsPage.vue: -------------------------------------------------------------------------------- 1 | 59 | 60 | 111 | 112 | 118 | -------------------------------------------------------------------------------- /admin/src/components/PageDetailsPreview.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 33 | 34 | 43 | -------------------------------------------------------------------------------- /admin/src/components/PageList.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 74 | 75 | 77 | -------------------------------------------------------------------------------- /admin/src/components/Schema.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 26 | 27 | 38 | -------------------------------------------------------------------------------- /admin/src/components/SchemaItems.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 63 | 64 | 88 | -------------------------------------------------------------------------------- /admin/src/components/User.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 53 | 54 | 56 | -------------------------------------------------------------------------------- /admin/src/components/__tests__/HelloWorld.cy.js: -------------------------------------------------------------------------------- 1 | import HelloWorld from '../HelloWorld.vue' 2 | 3 | describe('HelloWorld', () => { 4 | it('playground', () => { 5 | cy.mount(HelloWorld, { props: { msg: 'Hello Cypress' } }) 6 | }) 7 | 8 | it('renders properly', () => { 9 | cy.mount(HelloWorld, { props: { msg: 'Hello Cypress' } }) 10 | cy.get('h1').should('contain', 'Hello Cypress') 11 | }) 12 | }) 13 | -------------------------------------------------------------------------------- /admin/src/fields/Audio.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 72 | 73 | 75 | -------------------------------------------------------------------------------- /admin/src/fields/Checkbox.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 35 | -------------------------------------------------------------------------------- /admin/src/fields/Color.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 43 | -------------------------------------------------------------------------------- /admin/src/fields/Combobox.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 31 | -------------------------------------------------------------------------------- /admin/src/fields/Date.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | 65 | -------------------------------------------------------------------------------- /admin/src/fields/Html.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 54 | -------------------------------------------------------------------------------- /admin/src/fields/Image.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 98 | 99 | 106 | -------------------------------------------------------------------------------- /admin/src/fields/Markdown.vue: -------------------------------------------------------------------------------- 1 | 50 | 51 | 60 | 61 | -------------------------------------------------------------------------------- /admin/src/fields/Number.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 65 | -------------------------------------------------------------------------------- /admin/src/fields/Plaintext.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | 62 | -------------------------------------------------------------------------------- /admin/src/fields/Radio.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 40 | -------------------------------------------------------------------------------- /admin/src/fields/Range.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 44 | -------------------------------------------------------------------------------- /admin/src/fields/Select.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 57 | -------------------------------------------------------------------------------- /admin/src/fields/Slider.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 44 | -------------------------------------------------------------------------------- /admin/src/fields/String.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | 62 | -------------------------------------------------------------------------------- /admin/src/fields/Switch.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 37 | -------------------------------------------------------------------------------- /admin/src/fields/Table.vue: -------------------------------------------------------------------------------- 1 | 50 | 51 | 69 | -------------------------------------------------------------------------------- /admin/src/fields/Text.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 59 | 60 | -------------------------------------------------------------------------------- /admin/src/fields/Url.vue: -------------------------------------------------------------------------------- 1 | 56 | 57 | 73 | -------------------------------------------------------------------------------- /admin/src/fields/Video.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 74 | 75 | 81 | -------------------------------------------------------------------------------- /admin/src/graphql.js: -------------------------------------------------------------------------------- 1 | import { BatchHttpLink } from "apollo-link-batch-http" 2 | import { createApolloProvider } from '@vue/apollo-option' 3 | import { ApolloClient, ApolloLink, InMemoryCache } from '@apollo/client/core' 4 | import createUploadLink from 'apollo-upload-client/createUploadLink.mjs' 5 | 6 | 7 | const node = document.querySelector('#app') 8 | const httpLink = ApolloLink.split( 9 | operation => operation.getContext().hasUpload, 10 | createUploadLink({ 11 | uri: node?.dataset?.urlgraphql || '/graphql', 12 | credentials: 'include' 13 | }), 14 | new BatchHttpLink({ 15 | uri: node?.dataset?.urlgraphql || '/graphql', 16 | batchMax: 50, 17 | batchInterval: 20, 18 | credentials: 'include' 19 | }) 20 | ) 21 | 22 | const apolloClient = new ApolloClient({cache: new InMemoryCache(), link: httpLink}) 23 | const apolloProvider = createApolloProvider({defaultClient: apolloClient}) 24 | 25 | export default apolloProvider 26 | export { apolloClient } -------------------------------------------------------------------------------- /admin/src/log.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Logging plugin for the application 3 | */ 4 | export default { 5 | install(app) { 6 | app.config.globalProperties.$log = (...args) => { 7 | console.error(...args) 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /admin/src/main.js: -------------------------------------------------------------------------------- 1 | import { createPinia } from 'pinia' 2 | import { createApp, defineAsyncComponent } from 'vue' 3 | import VueObserveVisibility from 'vue3-observe-visibility' 4 | import apolloProvider from './graphql' 5 | 6 | import '@mdi/font/css/materialdesignicons.css' 7 | import 'vuetify/styles' 8 | import './assets/base.css' 9 | 10 | import { createVuetify } from 'vuetify' 11 | import * as components from 'vuetify/components' 12 | import * as directives from 'vuetify/directives' 13 | 14 | import logger from './log' 15 | import router from './routes' 16 | import App from './App.vue' 17 | 18 | 19 | const app = createApp(App) 20 | const pinia = createPinia() 21 | const vuetify = createVuetify({ 22 | components, 23 | directives, 24 | icons: { 25 | defaultSet: 'mdi', 26 | }, 27 | theme: { 28 | themes: { 29 | light: { 30 | dark: false, 31 | colors: { 32 | primary: '#40749b', 33 | background: '#F8FAFC' 34 | } 35 | }, 36 | }, 37 | }, 38 | }) 39 | 40 | 41 | const fields = import.meta.glob("@/fields/*.vue"); 42 | 43 | for(const path in fields) { 44 | const name = path.split("/").at(-1).split(".")[0] 45 | app.component(name, defineAsyncComponent(fields[path])); 46 | } 47 | 48 | 49 | app.use(logger) 50 | .use(pinia) 51 | .use(router) 52 | .use(vuetify) 53 | .use(apolloProvider) 54 | .use(VueObserveVisibility) 55 | .mount('#app') 56 | -------------------------------------------------------------------------------- /admin/src/routes.js: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHistory } from 'vue-router' 2 | import { useAuthStore, useMessageStore } from './stores' 3 | 4 | const router = createRouter({ 5 | history: createWebHistory(document.querySelector('#app')?.dataset?.urlbase || ''), 6 | routes: [ 7 | { 8 | path: '/', 9 | name: 'login', 10 | component: () => import('./views/LoginView.vue') 11 | }, 12 | { 13 | path: '/pages', 14 | name: 'page:view', 15 | component: () => import('./components/PageList.vue'), 16 | meta: { 17 | auth: true 18 | } 19 | }, 20 | { 21 | path: '/elements', 22 | name: 'element:view', 23 | component: () => import('./components/ElementList.vue'), 24 | meta: { 25 | auth: true 26 | } 27 | }, 28 | { 29 | path: '/files', 30 | name: 'file:view', 31 | component: () => import('./components/FileList.vue'), 32 | meta: { 33 | auth: true 34 | } 35 | } 36 | ] 37 | }) 38 | 39 | router.beforeEach(async (to, from, next) => { 40 | const auth = useAuthStore() 41 | const message = useMessageStore() 42 | const authenticated = await auth.isAuthenticated() 43 | 44 | if(to.matched.some(record => record.meta.auth) && !authenticated) { 45 | auth.intended(to.fullPath) 46 | next({name: 'login'}) 47 | } else if(to.name !== 'login' && !auth.can(to.name)) { 48 | message.add('You do not have permission to access ' + to.fullPath, 'error') 49 | return next(false) 50 | } else { 51 | next() 52 | } 53 | }) 54 | 55 | export default router 56 | -------------------------------------------------------------------------------- /admin/src/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Generates a unique content ID based on the current date and time. 3 | * 4 | * @returns String Unique content ID 5 | */ 6 | const contentid = (function () { 7 | const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_' 8 | const EPOCH = new Date('2025-01-01T00:00:00Z').getTime(); 9 | 10 | let counter = 0 11 | 12 | return function() { 13 | counter = (counter + 1) % 16 14 | 15 | // IDs will repeat after 45.3 years 16 | const time = Math.floor(Date.now() - EPOCH) / 333 17 | const value = (time << 4) | counter; 18 | 19 | return Array.from({ length: 6 }, (_, i) => 20 | BASE64[(value >> (6 * (5 - i))) & 63] 21 | ).join(''); 22 | } 23 | })() 24 | 25 | export { contentid } 26 | -------------------------------------------------------------------------------- /admin/vite.config.js: -------------------------------------------------------------------------------- 1 | import { fileURLToPath, URL } from 'node:url' 2 | 3 | import { defineConfig } from 'vite' 4 | import vue from '@vitejs/plugin-vue' 5 | import vuetify from 'vite-plugin-vuetify' 6 | 7 | // https://vitejs.dev/config/ 8 | export default defineConfig({ 9 | plugins: [ 10 | vue(), 11 | vuetify() 12 | ], 13 | resolve: { 14 | alias: { 15 | '@': fileURLToPath(new URL('./src', import.meta.url)) 16 | } 17 | }, 18 | build: { 19 | rollupOptions: { 20 | output: { 21 | entryFileNames: '[name].js', 22 | assetFileNames: (asset) => { 23 | return asset.names.includes('index.css') ? 'index.css' : 'assets/[name]-[hash][extname]' 24 | } 25 | } 26 | } 27 | }, 28 | experimental: { 29 | renderBuiltUrl: (filename, { type, hostType }) => { 30 | if(type === 'asset' && ['css', 'html'].includes(hostType) === false) { 31 | return { runtime: `new URL(${JSON.stringify(filename)}, import.meta.url).href` } 32 | } else { 33 | return { relative: true } 34 | } 35 | }, 36 | }, 37 | }) 38 | -------------------------------------------------------------------------------- /assets/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aimeos/laravel-cms/b449f93e5b9e370dc0e521e6d2cd5edfa5bfbcf7/assets/android-chrome-192x192.png -------------------------------------------------------------------------------- /assets/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aimeos/laravel-cms/b449f93e5b9e370dc0e521e6d2cd5edfa5bfbcf7/assets/android-chrome-512x512.png -------------------------------------------------------------------------------- /assets/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aimeos/laravel-cms/b449f93e5b9e370dc0e521e6d2cd5edfa5bfbcf7/assets/apple-touch-icon.png -------------------------------------------------------------------------------- /assets/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aimeos/laravel-cms/b449f93e5b9e370dc0e521e6d2cd5edfa5bfbcf7/assets/favicon-16x16.png -------------------------------------------------------------------------------- /assets/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aimeos/laravel-cms/b449f93e5b9e370dc0e521e6d2cd5edfa5bfbcf7/assets/favicon-32x32.png -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aimeos/laravel-cms", 3 | "description": "Laravel CMS", 4 | "keywords": ["laravel", "cms", "package", "api", "graphql", "jsonapi", "multi-tenant", "multi-language"], 5 | "homepage": "https://laravel-cms.org", 6 | "type": "library", 7 | "license": "MIT", 8 | "prefer-stable": true, 9 | "minimum-stability": "dev", 10 | "require": { 11 | "laravel/framework": "^11.0||^12.0", 12 | "laravel-json-api/laravel": "^5.1", 13 | "mll-lab/graphql-php-scalars": "^6.4", 14 | "nuwave/lighthouse": "^6.54", 15 | "ezyang/htmlpurifier": "^4.18", 16 | "league/commonmark": "^2.6", 17 | "kalnoy/nestedset": "^6.0", 18 | "symfony/uid": "^7.0", 19 | "intervention/image": "^3.11" 20 | }, 21 | "require-dev": { 22 | "orchestra/testbench": "^9.0||^10.0", 23 | "laravel-json-api/testing": "^3.1" 24 | }, 25 | "autoload": { 26 | "psr-4": { 27 | "Aimeos\\Cms\\": "src", 28 | "Database\\Seeders\\": "database/seeders/" 29 | }, 30 | "files": [ 31 | "src/helpers.php" 32 | ], 33 | "classmap": [ 34 | "src" 35 | ] 36 | }, 37 | "autoload-dev": { 38 | "psr-4": { 39 | "Aimeos\\Cms\\": "tests" 40 | }, 41 | "classmap": [ 42 | "tests" 43 | ] 44 | }, 45 | "extra": { 46 | "laravel": { 47 | "providers": [ 48 | "Aimeos\\Cms\\CmsServiceProvider" 49 | ] 50 | } 51 | }, 52 | "scripts": { 53 | "post-autoload-dump": [ 54 | "@php vendor/bin/testbench package:discover --ansi" 55 | ] 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /config/cms.php: -------------------------------------------------------------------------------- 1 | env( 'DB_CONNECTION', 'mysql' ), 15 | 16 | /* 17 | |-------------------------------------------------------------------------- 18 | | Cache store 19 | |-------------------------------------------------------------------------- 20 | | 21 | | Use the cache store defined in ./config/cache.php to store rendered pages 22 | | for fast response times. 23 | | 24 | */ 25 | 'cache' => env( 'APP_DEBUG' ) ? 'array' : 'file', 26 | 27 | /* 28 | |-------------------------------------------------------------------------- 29 | | Filesystem disk 30 | |-------------------------------------------------------------------------- 31 | | 32 | | Use the filesystem disk defined in ./config/filesystems.php to store the 33 | | uploaded files. By default, they are stored in the ./public/storage/cms/ 34 | | folder but this can be any supported cloud storage too. 35 | | 36 | */ 37 | 'disk' => env( 'CMS_DISK', 'public' ), 38 | 39 | /* 40 | |-------------------------------------------------------------------------- 41 | | Prune deleted records 42 | |-------------------------------------------------------------------------- 43 | | 44 | | Number of days after deleted pages, elements and files will be finally 45 | | removed. Disable pruning with FALSE as value. 46 | | 47 | */ 48 | 'prune' => 30, 49 | 50 | /* 51 | |-------------------------------------------------------------------------- 52 | | Page template 53 | |-------------------------------------------------------------------------- 54 | | 55 | | The configured Blade template will be used for rendering all CMS pages. 56 | | 57 | */ 58 | 'view' => 'cms::page', 59 | ]; -------------------------------------------------------------------------------- /database/migrations/2023_02_18_202800_create_pages_table.php: -------------------------------------------------------------------------------- 1 | create('cms_pages', function (Blueprint $table) { 17 | $table->id(); 18 | $table->string('tenant_id', 250); 19 | $table->string('name'); 20 | $table->string('slug'); 21 | $table->string('to'); 22 | $table->string('title'); 23 | $table->string('domain'); 24 | $table->string('lang', 5)->nullable(); 25 | $table->string('tag', 30); 26 | $table->string('type', 30); 27 | $table->string('theme', 30); 28 | $table->smallInteger('cache'); 29 | $table->smallInteger('status'); 30 | $table->json('meta'); 31 | $table->json('config'); 32 | $table->json('contents'); 33 | $table->string('editor'); 34 | $table->softDeletes(); 35 | $table->timestamps(); 36 | $table->nestedSet(); 37 | 38 | $table->unique(['slug', 'domain', 'lang', 'tenant_id']); 39 | $table->index(['_lft', '_rgt', 'tenant_id', 'status']); 40 | $table->index(['tag', 'lang', 'tenant_id', 'status']); 41 | $table->index(['lang', 'tenant_id', 'status']); 42 | $table->index(['parent_id', 'tenant_id']); 43 | $table->index(['domain', 'tenant_id']); 44 | $table->index(['to', 'tenant_id']); 45 | $table->index(['name', 'tenant_id']); 46 | $table->index(['title', 'tenant_id']); 47 | $table->index(['type', 'tenant_id']); 48 | $table->index(['theme', 'tenant_id']); 49 | $table->index(['cache', 'tenant_id']); 50 | $table->index(['editor', 'tenant_id']); 51 | $table->index(['deleted_at']); 52 | }); 53 | } 54 | 55 | /** 56 | * Reverse the migrations. 57 | * 58 | * @return void 59 | */ 60 | public function down() 61 | { 62 | Schema::connection(config('cms.db', 'sqlite'))->dropIfExists('cms_pages'); 63 | } 64 | }; 65 | -------------------------------------------------------------------------------- /database/migrations/2023_02_18_202810_create_elements_table.php: -------------------------------------------------------------------------------- 1 | create('cms_elements', function (Blueprint $table) { 17 | $table->uuid('id'); 18 | $table->string('tenant_id'); 19 | $table->string('type', 50); 20 | $table->string('lang', 5)->nullable(); 21 | $table->string('name'); 22 | $table->json('data'); 23 | $table->string('editor'); 24 | $table->timestamps(); 25 | $table->softDeletes(); 26 | 27 | $table->primary('id'); 28 | $table->index(['type', 'tenant_id']); 29 | $table->index(['lang', 'tenant_id']); 30 | $table->index(['name', 'tenant_id']); 31 | $table->index(['editor', 'tenant_id']); 32 | $table->index('deleted_at'); 33 | }); 34 | } 35 | 36 | /** 37 | * Reverse the migrations. 38 | * 39 | * @return void 40 | */ 41 | public function down() 42 | { 43 | Schema::connection(config('cms.db', 'sqlite'))->dropIfExists('cms_elements'); 44 | } 45 | }; 46 | -------------------------------------------------------------------------------- /database/migrations/2023_02_18_202820_create_files_table.php: -------------------------------------------------------------------------------- 1 | create('cms_files', function (Blueprint $table) { 17 | $table->uuid('id'); 18 | $table->string('tenant_id'); 19 | $table->string('mime', 100); 20 | $table->string('tag', 30); 21 | $table->string('name'); 22 | $table->string('path'); 23 | $table->json('previews'); 24 | $table->json('description'); 25 | $table->string('editor'); 26 | $table->softDeletes(); 27 | $table->timestamps(); 28 | 29 | $table->primary('id'); 30 | $table->index(['mime', 'tenant_id']); 31 | $table->index(['tag', 'tenant_id']); 32 | $table->index(['name', 'tenant_id']); 33 | $table->index(['editor', 'tenant_id']); 34 | $table->index('deleted_at'); 35 | }); 36 | } 37 | 38 | /** 39 | * Reverse the migrations. 40 | * 41 | * @return void 42 | */ 43 | public function down() 44 | { 45 | Schema::connection(config('cms.db', 'sqlite'))->dropIfExists('cms_files'); 46 | } 47 | }; 48 | -------------------------------------------------------------------------------- /database/migrations/2023_02_18_202830_create_versions_table.php: -------------------------------------------------------------------------------- 1 | create('cms_versions', function (Blueprint $table) { 17 | $table->uuid('id'); 18 | $table->string('tenant_id'); 19 | $table->string('versionable_id', 36); 20 | $table->string('versionable_type', 50); 21 | $table->boolean('published'); 22 | $table->datetime('publish_at')->nullable(); 23 | $table->string('lang', 5)->nullable(); 24 | $table->json('contents')->nullable(); 25 | $table->json('data'); 26 | $table->string('editor'); 27 | $table->timestamp('created_at'); 28 | 29 | $table->primary('id'); 30 | $table->index(['versionable_id', 'versionable_type', 'created_at', 'tenant_id'], 'idx_versions_id_type_created_tenantid'); 31 | $table->index(['publish_at', 'published']); 32 | }); 33 | } 34 | 35 | 36 | /** 37 | * Reverse the migrations. 38 | * 39 | * @return void 40 | */ 41 | public function down() 42 | { 43 | Schema::connection(config('cms.db', 'sqlite'))->dropIfExists('cms_versions'); 44 | } 45 | }; 46 | -------------------------------------------------------------------------------- /database/migrations/2023_02_18_202840_create_page_element_table.php: -------------------------------------------------------------------------------- 1 | create('cms_page_element', function (Blueprint $table) { 17 | $table->foreignId('page_id')->constrained('cms_pages')->cascadeOnUpdate()->cascadeOnDelete(); 18 | $table->foreignUuid('element_id')->constrained('cms_elements')->cascadeOnUpdate()->cascadeOnDelete(); 19 | 20 | $table->unique(['page_id', 'element_id']); 21 | }); 22 | } 23 | 24 | /** 25 | * Reverse the migrations. 26 | * 27 | * @return void 28 | */ 29 | public function down() 30 | { 31 | Schema::connection(config('cms.db', 'sqlite'))->dropIfExists('cms_page_element'); 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /database/migrations/2023_02_18_202840_create_version_element_table.php: -------------------------------------------------------------------------------- 1 | create('cms_version_element', function (Blueprint $table) { 17 | $table->foreignUuid('version_id')->constrained('cms_versions')->cascadeOnUpdate()->cascadeOnDelete(); 18 | $table->foreignUuid('element_id')->constrained('cms_elements')->cascadeOnUpdate()->cascadeOnDelete(); 19 | 20 | $table->unique(['version_id', 'element_id']); 21 | }); 22 | } 23 | 24 | /** 25 | * Reverse the migrations. 26 | * 27 | * @return void 28 | */ 29 | public function down() 30 | { 31 | Schema::connection(config('cms.db', 'sqlite'))->dropIfExists('cms_version_element'); 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /database/migrations/2023_02_18_202850_create_element_file_table.php: -------------------------------------------------------------------------------- 1 | create('cms_element_file', function (Blueprint $table) { 17 | $table->foreignUuid('element_id')->constrained('cms_elements')->cascadeOnUpdate()->cascadeOnDelete(); 18 | $table->foreignUuid('file_id')->constrained('cms_files')->cascadeOnUpdate()->cascadeOnDelete(); 19 | 20 | $table->unique(['element_id', 'file_id']); 21 | }); 22 | } 23 | 24 | /** 25 | * Reverse the migrations. 26 | * 27 | * @return void 28 | */ 29 | public function down() 30 | { 31 | Schema::connection(config('cms.db', 'sqlite'))->dropIfExists('cms_element_file'); 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /database/migrations/2023_02_18_202850_create_page_file_table.php: -------------------------------------------------------------------------------- 1 | create('cms_page_file', function (Blueprint $table) { 17 | $table->foreignId('page_id')->constrained('cms_pages')->cascadeOnUpdate()->cascadeOnDelete(); 18 | $table->foreignUuid('file_id')->constrained('cms_files')->cascadeOnUpdate()->cascadeOnDelete(); 19 | 20 | $table->unique(['page_id', 'file_id']); 21 | }); 22 | } 23 | 24 | /** 25 | * Reverse the migrations. 26 | * 27 | * @return void 28 | */ 29 | public function down() 30 | { 31 | Schema::connection(config('cms.db', 'sqlite'))->dropIfExists('cms_page_file'); 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /database/migrations/2023_02_18_202850_create_version_file_table.php: -------------------------------------------------------------------------------- 1 | create('cms_version_file', function (Blueprint $table) { 17 | $table->foreignUuid('version_id')->constrained('cms_versions')->cascadeOnUpdate()->cascadeOnDelete(); 18 | $table->foreignUuid('file_id')->constrained('cms_files')->cascadeOnUpdate()->cascadeOnDelete(); 19 | 20 | $table->unique(['version_id', 'file_id']); 21 | }); 22 | } 23 | 24 | /** 25 | * Reverse the migrations. 26 | * 27 | * @return void 28 | */ 29 | public function down() 30 | { 31 | Schema::connection(config('cms.db', 'sqlite'))->dropIfExists('cms_version_file'); 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /database/migrations/2023_02_18_202920_add_users_cmseditor.php: -------------------------------------------------------------------------------- 1 | integer('cmseditor')->default(0); 18 | }); 19 | } 20 | 21 | 22 | /** 23 | * Reverse the migrations. 24 | * 25 | * @return void 26 | */ 27 | public function down() 28 | { 29 | Schema::table('users', function (Blueprint $table) { 30 | $table->dropColumn('cmseditor'); 31 | }); 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /docs/graphql-authentication.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Authentication" 3 | permalink: /graphql/authentication/ 4 | excerpt: "How to authenticate at Laravel CMS using the GraphQL API" 5 | --- 6 | 7 | * [Login](#login) 8 | * [Retrieve user](#retrieve-user) 9 | * [Logout](#logout) 10 | 11 | Laravel CMS tries to authenticate against entries of the Laravel `users` table. To be able to use the GraphQL API, they need to be editors (use the `artisan` command to set the editor role): 12 | 13 | ```bash 14 | php artisan cms:editor editor@example.com 15 | ``` 16 | 17 | ## Login 18 | 19 | To authenticate for editing content: 20 | 21 | ```graphql 22 | mutation { 23 | cmsLogin(email: "editor@example.com", password: "secret") { 24 | name 25 | email 26 | } 27 | } 28 | ``` 29 | 30 | ```json 31 | { 32 | "data": { 33 | "cmsLogin": { 34 | "name": "A CMS editor", 35 | "email": "editor@example.com" 36 | } 37 | } 38 | } 39 | ``` 40 | 41 | ## Retrieve user 42 | 43 | Retrieve information about the authenticated user: 44 | 45 | ```graphql 46 | query { 47 | me { 48 | name 49 | email 50 | } 51 | } 52 | ``` 53 | 54 | ```json 55 | { 56 | "data": { 57 | "me": { 58 | "name": "A CMS editor", 59 | "email": "editor@example.com" 60 | } 61 | } 62 | } 63 | ``` 64 | 65 | ## Logout 66 | 67 | To log the current user out of the application: 68 | 69 | ```graphql 70 | mutation { 71 | cmsLogout { 72 | name 73 | email 74 | } 75 | } 76 | ``` 77 | 78 | ```json 79 | { 80 | "data": { 81 | "cmsLogout": { 82 | "name": "A CMS editor", 83 | "email": "editor@example.com" 84 | } 85 | } 86 | } 87 | ``` 88 | -------------------------------------------------------------------------------- /docs/jsonapi-subpages-breadcrumb.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Sub-pages with breadcrumb" 3 | permalink: /jsonapi/subpages-breadcrumb/ 4 | excerpt: "How to retrieve the sub-pages with content and ancestors from Laravel CMS using the JSON:API to build page with breadcrumb" 5 | --- 6 | 7 | To retrieve a specific page whose URL you've gotten from one of the previous responses (in the `links/self` attribute of the item) with its ancestor pages for the breadcrumb and its shared content elements (add `?include=ancestors,elements` to the page URL): 8 | 9 | ``` 10 | http://mydomain.tld/api/cms/pages/2?include=ancestors,elements 11 | ``` 12 | 13 | The `included` section contains the list of shared element elements that should be displayed at the page if `include=elements` is added as parameter to the JSON:API URL the anchestor pages if the `include` parameter also contains `ancestors`, e.g.: 14 | 15 | ```json 16 | { 17 | "meta": { 18 | "baseurl": "http:\/\/mydomain.tld\/storage\/" 19 | }, 20 | "jsonapi": { 21 | "version": "1.0" 22 | }, 23 | "links": { 24 | "self": "http:\/\/mydomain.tld\/cms\/pages\/2" 25 | }, 26 | "data": { 27 | "type": "pages", 28 | "id": "2", 29 | "attributes": { 30 | "parent_id": 1, 31 | "lang": "", 32 | "slug": "blog", 33 | "name": "Blog", 34 | "title": "Blog | Laravel CMS", 35 | "tag": "blog", 36 | "to": "", 37 | "domain": "", 38 | "has": true, 39 | "cache": 5, 40 | "data": [ 41 | { 42 | "text": "Blog example", 43 | "type": "cms::heading" 44 | }, 45 | { 46 | "type": "cms::blog" 47 | } 48 | ], 49 | "meta": null, 50 | "config": null, 51 | "createdAt": "2023-05-01T09:36:30.000000Z", 52 | "updatedAt": "2023-05-01T09:36:30.000000Z" 53 | }, 54 | "relationships": { 55 | "elements": { 56 | "data": [] 57 | }, 58 | "ancestors": { 59 | "data": [ 60 | { 61 | "type": "pages", 62 | "id": "1" 63 | } 64 | ] 65 | } 66 | }, 67 | "links": { 68 | "self": "http:\/\/mydomain.tld\/cms\/pages\/2" 69 | } 70 | }, 71 | "included": [ 72 | { 73 | "type": "pages", 74 | "id": "1", 75 | "attributes": { 76 | "parent_id": null, 77 | "lang": "", 78 | "slug": "", 79 | "name": "Home", 80 | "title": "Home | Laravel CMS", 81 | "tag": "root", 82 | "to": "", 83 | "domain": "mydomain.tld", 84 | "has": true, 85 | "cache": 5, 86 | "data": null, 87 | "meta": null, 88 | "config": null, 89 | "createdAt": "2023-05-01T09:36:30.000000Z", 90 | "updatedAt": "2023-05-01T09:36:30.000000Z" 91 | }, 92 | "links": { 93 | "self": "http:\/\/mydomain.tld\/cms\/pages\/1" 94 | } 95 | } 96 | ] 97 | } 98 | ``` 99 | -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aimeos/laravel-cms/b449f93e5b9e370dc0e521e6d2cd5edfa5bfbcf7/favicon.ico -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "laravel-cms", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": {} 6 | } 7 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | ./src/ 15 | 16 | 17 | 18 | 19 | ./tests/ 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /public/article.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aimeos/laravel-cms/b449f93e5b9e370dc0e521e6d2cd5edfa5bfbcf7/public/article.css -------------------------------------------------------------------------------- /public/blog.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aimeos/laravel-cms/b449f93e5b9e370dc0e521e6d2cd5edfa5bfbcf7/public/blog.css -------------------------------------------------------------------------------- /public/cms.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aimeos/laravel-cms/b449f93e5b9e370dc0e521e6d2cd5edfa5bfbcf7/public/cms.css -------------------------------------------------------------------------------- /public/prism.css: -------------------------------------------------------------------------------- 1 | /* PrismJS 1.29.0 2 | https://prismjs.com/download.html#themes=prism-okaidia&languages=markup+css+clike+javascript+aspnet+bash+c+csharp+cpp+csp+css-extras+diff+docker+gettext+git+go+graphql+http+ini+java+json+kotlin+lua+makefile+markdown+markup-templating+nginx+objectivec+perl+php+php-extras+powershell+python+jsx+tsx+regex+ruby+rust+scss+scala+smalltalk+sql+toml+twig+typescript+typoscript+uri+visual-basic+wasm+xml-doc+yaml */ 3 | code[class*=language-],pre[class*=language-]{color:#f8f8f2;background:0 0;text-shadow:0 1px rgba(0,0,0,.3);font-family:Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto;border-radius:.3em}:not(pre)>code[class*=language-],pre[class*=language-]{background:#272822}:not(pre)>code[class*=language-]{padding:.1em;border-radius:.3em;white-space:normal}.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#8292a2}.token.punctuation{color:#f8f8f2}.token.namespace{opacity:.7}.token.constant,.token.deleted,.token.property,.token.symbol,.token.tag{color:#f92672}.token.boolean,.token.number{color:#ae81ff}.token.attr-name,.token.builtin,.token.char,.token.inserted,.token.selector,.token.string{color:#a6e22e}.language-css .token.string,.style .token.string,.token.entity,.token.operator,.token.url,.token.variable{color:#f8f8f2}.token.atrule,.token.attr-value,.token.class-name,.token.function{color:#e6db74}.token.keyword{color:#66d9ef}.token.important,.token.regex{color:#fd971f}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help} 4 | -------------------------------------------------------------------------------- /site.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} -------------------------------------------------------------------------------- /src/CmsServiceProvider.php: -------------------------------------------------------------------------------- 1 | loadViewsFrom( $basedir . '/views', 'cms' ); 31 | $this->loadMigrationsFrom( $basedir . '/database/migrations' ); 32 | 33 | $this->publishes( [$basedir . '/public' => public_path( 'vendor/cms' )], 'public' ); 34 | $this->publishes( [$basedir . '/config/cms.php' => config_path( 'cms.php' )], 'config' ); 35 | $this->publishes( [$basedir . '/admin/dist' => public_path( 'vendor/cms/admin' )], 'admin' ); 36 | $this->publishes( [$basedir . '/graphql' => base_path( 'graphql' )], 'admin' ); 37 | 38 | 39 | if( $this->app->runningInConsole() ) 40 | { 41 | $this->commands( [ 42 | \Aimeos\Cms\Commands\Install::class, 43 | \Aimeos\Cms\Commands\Publish::class, 44 | \Aimeos\Cms\Commands\Serve::class, 45 | \Aimeos\Cms\Commands\User::class, 46 | ] ); 47 | } 48 | 49 | \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::skipWhen( function( $request ) { 50 | return $request->is( trim( config( 'lighthouse.route.uri' ), '/' ) ); 51 | } ); 52 | } 53 | 54 | 55 | /** 56 | * Register the service provider. 57 | */ 58 | public function register() 59 | { 60 | } 61 | } -------------------------------------------------------------------------------- /src/Commands/Publish.php: -------------------------------------------------------------------------------- 1 | where( 'published', false ) 34 | ->chunk( 100, function( $versions ) { 35 | 36 | foreach( $versions as $version ) 37 | { 38 | try 39 | { 40 | $id = $version->versionable_id; 41 | $type = $version->versionable_type; 42 | 43 | app( $type )::findOrFail( $id )->publish( $version ); 44 | } 45 | catch( \Exception $e ) 46 | { 47 | $this->error( "Failed to publish ID {$id} of {$type}: " . $e->getMessage() ); 48 | } 49 | } 50 | 51 | } ); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Commands/Serve.php: -------------------------------------------------------------------------------- 1 | host().':'.$this->port(), 42 | '-d', 43 | 'upload_max_filesize=100M', 44 | '-d', 45 | 'post_max_size=100M', 46 | __DIR__.'/../server.php', 47 | ]; 48 | } 49 | 50 | 51 | /** 52 | * Get the console command options. 53 | * 54 | * @return array 55 | */ 56 | protected function getOptions() 57 | { 58 | return [ 59 | ['host', null, InputOption::VALUE_OPTIONAL, 'The host address to serve the application on', Env::get('SERVER_HOST', 'localhost')], 60 | ['port', null, InputOption::VALUE_OPTIONAL, 'The port to serve the application on', Env::get('SERVER_PORT')], 61 | ['tries', null, InputOption::VALUE_OPTIONAL, 'The max number of ports to attempt to serve from', 10], 62 | ['no-reload', null, InputOption::VALUE_NONE, 'Do not reload the development server on .env file changes'], 63 | ]; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Commands/User.php: -------------------------------------------------------------------------------- 1 | argument( 'email' ); 36 | $value = $this->option( 'disable' ) ? 0 : 0x7fffffff; 37 | 38 | if( ( $user = \Illuminate\Foundation\Auth\User::where( 'email', $email )->first() ) === null ) 39 | { 40 | $user = (new \Illuminate\Foundation\Auth\User())->forceFill( [ 41 | 'password' => Hash::make( $this->option( 'password' ) ?: $this->secret( 'Password' ) ), 42 | 'email' => $email, 43 | 'name' => $email, 44 | ] ); 45 | } 46 | 47 | $user->forceFill( ['cmseditor' => $value] )->save(); 48 | 49 | if( $value ) { 50 | $this->info( sprintf( ' Enabled [%1$s] as CMS user', $email ) ); 51 | } else { 52 | $this->info( sprintf( ' Disabled [%1$s] as CMS user', $email ) ); 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Concerns/Tenancy.php: -------------------------------------------------------------------------------- 1 | setAttribute( $model->getTenantColumn(), \Aimeos\Cms\Tenancy::value() ); 38 | } ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/GraphQL/Exception.php: -------------------------------------------------------------------------------- 1 | reason = $reason; 17 | } 18 | 19 | 20 | /** 21 | * Returns true when exception message is safe to be displayed to a client. 22 | */ 23 | public function isClientSafe(): bool 24 | { 25 | return true; 26 | } 27 | 28 | 29 | /** 30 | * Data to include within the "extensions" key of the formatted error. 31 | * 32 | * @return array 33 | */ 34 | public function getExtensions(): array 35 | { 36 | return ['reason' => $this->reason]; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/GraphQL/Mutations/AddElement.php: -------------------------------------------------------------------------------- 1 | transaction( function() use ( $element, $args ) { 21 | 22 | $editor = Auth::user()?->name ?? request()->ip(); 23 | 24 | $element->fill( $args['input'] ?? [] ); 25 | $element->tenant_id = \Aimeos\Cms\Tenancy::value(); 26 | $element->data = $args['input']['data'] ?? []; 27 | $element->editor = $editor; 28 | $element->save(); 29 | 30 | $element->files()->attach( $args['files'] ?? [] ); 31 | 32 | }, 3 ); 33 | 34 | return $element; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/GraphQL/Mutations/AddFile.php: -------------------------------------------------------------------------------- 1 | isValid() ) { 20 | throw new Exception( 'Invalid file upload' ); 21 | } 22 | 23 | $file = new File(); 24 | $file->fill( $args['input'] ?? [] ); 25 | $file->editor = Auth::user()?->name ?? request()->ip(); 26 | $file->name = $file->name ?: pathinfo( $upload->getClientOriginalName(), PATHINFO_BASENAME ); 27 | $file->mime = $upload->getClientMimeType(); 28 | 29 | try 30 | { 31 | $file->addFile( $upload )->addPreviews( $args['preview'] ?? $upload ); 32 | $file->save(); 33 | } 34 | catch( \Throwable $t ) 35 | { 36 | $file->removePreviews()->removeFile(); 37 | throw $t; 38 | } 39 | 40 | return $file; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/GraphQL/Mutations/AddPage.php: -------------------------------------------------------------------------------- 1 | transaction( function() use ( $page, $args ) { 21 | 22 | $editor = Auth::user()?->name ?? request()->ip(); 23 | 24 | $page->fill( $args['input'] ?? [] ); 25 | $page->tenant_id = \Aimeos\Cms\Tenancy::value(); 26 | $page->editor = $editor; 27 | 28 | if( isset( $args['ref'] ) ) { 29 | $page->beforeNode( Page::withTrashed()->findOrFail( $args['ref'] ) ); 30 | } 31 | elseif( isset( $args['parent'] ) ) { 32 | $page->appendToNode( Page::withTrashed()->findOrFail( $args['parent'] ) ); 33 | } 34 | 35 | $page->save(); 36 | $page->files()->attach( $args['files'] ?? [] ); 37 | $page->elements()->attach( $args['elements'] ?? [] ); 38 | 39 | }, 3 ); 40 | 41 | return $page; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/GraphQL/Mutations/CmsLogin.php: -------------------------------------------------------------------------------- 1 | $args 15 | */ 16 | public function __invoke( $rootValue, array $args ): User 17 | { 18 | $guard = Auth::guard(); 19 | 20 | if( !$guard->attempt( $args ) ) { 21 | throw new Exception( 'Invalid credentials' ); 22 | } 23 | 24 | return $guard->user(); 25 | } 26 | } -------------------------------------------------------------------------------- /src/GraphQL/Mutations/CmsLogout.php: -------------------------------------------------------------------------------- 1 | $args 14 | */ 15 | public function __invoke( $rootValue, array $args ): ?User 16 | { 17 | $guard = Auth::guard(); 18 | $user = $guard->user(); 19 | 20 | $guard->logout(); 21 | return $user; 22 | } 23 | } -------------------------------------------------------------------------------- /src/GraphQL/Mutations/DropElement.php: -------------------------------------------------------------------------------- 1 | whereIn( 'id', $args['id'] )->get(); 18 | $editor = Auth::user()?->name ?? request()->ip(); 19 | 20 | foreach( $items as $item ) 21 | { 22 | $item->editor = $editor; 23 | $item->delete(); 24 | } 25 | 26 | return $items->all(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/GraphQL/Mutations/DropFile.php: -------------------------------------------------------------------------------- 1 | whereIn( 'id', $args['id'] )->get(); 18 | $editor = Auth::user()?->name ?? request()->ip(); 19 | 20 | foreach( $items as $item ) 21 | { 22 | $item->editor = $editor; 23 | $item->delete(); 24 | } 25 | 26 | return $items->all(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/GraphQL/Mutations/DropPage.php: -------------------------------------------------------------------------------- 1 | whereIn( 'id', $args['id'] )->get(); 19 | $editor = Auth::user()?->name ?? request()->ip(); 20 | 21 | foreach( $items as $item ) 22 | { 23 | $item->editor = $editor; 24 | $item->delete(); 25 | 26 | Cache::forget( Page::key( $item ) ); 27 | } 28 | 29 | return $items->all(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/GraphQL/Mutations/KeepElement.php: -------------------------------------------------------------------------------- 1 | whereIn( 'id', $args['id'] )->get(); 18 | $editor = Auth::user()?->name ?? request()->ip(); 19 | 20 | foreach( $items as $item ) 21 | { 22 | $item->editor = $editor; 23 | $item->restore(); 24 | } 25 | 26 | return $items->all(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/GraphQL/Mutations/KeepFile.php: -------------------------------------------------------------------------------- 1 | whereIn( 'id', $args['id'] )->get(); 18 | $editor = Auth::user()?->name ?? request()->ip(); 19 | 20 | foreach( $items as $item ) 21 | { 22 | $item->editor = $editor; 23 | $item->restore(); 24 | } 25 | 26 | return $items->all(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/GraphQL/Mutations/KeepPage.php: -------------------------------------------------------------------------------- 1 | whereIn( 'id', $args['id'] )->get(); 18 | $editor = Auth::user()?->name ?? request()->ip(); 19 | 20 | foreach( $items as $item ) 21 | { 22 | $item->editor = $editor; 23 | $item->restore(); 24 | } 25 | 26 | return $items->all(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/GraphQL/Mutations/MovePage.php: -------------------------------------------------------------------------------- 1 | findOrFail( $args['id'] ); 19 | $page->editor = Auth::user()?->name ?? request()->ip(); 20 | 21 | if( isset( $args['ref'] ) ) { 22 | $page->beforeNode( Page::withTrashed()->findOrFail( $args['ref'] ) ); 23 | } 24 | elseif( isset( $args['parent'] ) ) { 25 | $page->appendToNode( Page::withTrashed()->findOrFail( $args['parent'] ) ); 26 | } 27 | else { 28 | DB::connection( config( 'cms.db', 'sqlite' ) )->transaction( fn() => $page->saveAsRoot(), 3 ); 29 | return $page; 30 | } 31 | 32 | DB::connection( config( 'cms.db', 'sqlite' ) )->transaction( fn() => $page->save(), 3 ); 33 | 34 | return $page; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/GraphQL/Mutations/PubElement.php: -------------------------------------------------------------------------------- 1 | whereIn( 'id', $args['id'] )->get(); 18 | $editor = Auth::user()?->name ?? request()->ip(); 19 | 20 | foreach( $items as $item ) 21 | { 22 | if( $latest = $item->latest ) 23 | { 24 | if( $args['at'] ?? null ) 25 | { 26 | $latest->publish_at = $args['at']; 27 | $latest->editor = $editor; 28 | $latest->save(); 29 | continue; 30 | } 31 | 32 | $item->publish( $latest ); 33 | } 34 | } 35 | 36 | return $items->all(); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/GraphQL/Mutations/PubFile.php: -------------------------------------------------------------------------------- 1 | whereIn( 'id', $args['id'] )->get(); 18 | $editor = Auth::user()?->name ?? request()->ip(); 19 | 20 | foreach( $items as $item ) 21 | { 22 | if( $latest = $item->latest ) 23 | { 24 | if( $args['at'] ?? null ) 25 | { 26 | $latest->publish_at = $args['at']; 27 | $latest->editor = $editor; 28 | $latest->save(); 29 | continue; 30 | } 31 | 32 | $item->publish( $latest ); 33 | } 34 | } 35 | 36 | return $items->all(); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/GraphQL/Mutations/PubPage.php: -------------------------------------------------------------------------------- 1 | whereIn( 'id', $args['id'] )->get(); 19 | $editor = Auth::user()?->name ?? request()->ip(); 20 | 21 | foreach( $items as $item ) 22 | { 23 | if( $latest = $item->latest ) 24 | { 25 | if( $args['at'] ?? null ) 26 | { 27 | $latest->publish_at = $args['at']; 28 | $latest->editor = $editor; 29 | $latest->save(); 30 | continue; 31 | } 32 | 33 | $item->publish( $latest ); 34 | Cache::forget( Page::key( $item ) ); 35 | } 36 | } 37 | 38 | return $items->all(); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/GraphQL/Mutations/PurgeElement.php: -------------------------------------------------------------------------------- 1 | whereIn( 'id', $args['id'] )->get(); 17 | Element::whereIn( 'id', $items->pluck( 'id' ) )->forceDelete(); 18 | 19 | return $items->all(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/GraphQL/Mutations/PurgeFile.php: -------------------------------------------------------------------------------- 1 | whereIn( 'id', $args['id'] )->get(); 19 | 20 | foreach( $items as $item ) { 21 | $item->purge(); 22 | } 23 | 24 | return $items->all(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/GraphQL/Mutations/PurgePage.php: -------------------------------------------------------------------------------- 1 | whereIn( 'id', $args['id'] )->get(); 19 | 20 | foreach( $items as $item ) 21 | { 22 | DB::connection( config( 'cms.db', 'sqlite' ) )->transaction( fn() => $item->forceDelete(), 3 ); 23 | Cache::forget( Page::key( $item ) ); 24 | } 25 | 26 | return $items->all(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/GraphQL/Mutations/SaveElement.php: -------------------------------------------------------------------------------- 1 | findOrFail( $args['id'] ); 20 | 21 | DB::connection( config( 'cms.db', 'sqlite' ) )->transaction( function() use ( $element, $args ) { 22 | 23 | $version = $element->versions()->create( [ 24 | 'editor' => Auth::user()?->name ?? request()->ip(), 25 | 'lang' => $args['input']['lang'] ?? '', 26 | 'data' => $args['input'] ?? [], 27 | ] ); 28 | 29 | $version->files()->sync( $args['files'] ?? [] ); 30 | 31 | // MySQL doesn't support offsets for DELETE 32 | $ids = Version::where( 'versionable_id', $element->id ) 33 | ->where( 'versionable_type', Element::class ) 34 | ->orderBy( 'id', 'desc' ) 35 | ->skip( 10 ) 36 | ->take( 10 ) 37 | ->pluck( 'id' ); 38 | 39 | if( !$ids->isEmpty() ) { 40 | Version::whereIn( 'id', $ids )->forceDelete(); 41 | } 42 | 43 | }, 3 ); 44 | 45 | return $element; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/GraphQL/Mutations/SaveFile.php: -------------------------------------------------------------------------------- 1 | findOrFail( $args['id'] ); 19 | $file = clone $orig; 20 | $file->fill( $args['input'] ?? [] ); 21 | 22 | $upload = $args['file'] ?? null; 23 | 24 | if( $upload instanceof UploadedFile ) { 25 | $file->addFile( $upload ); 26 | } 27 | 28 | try 29 | { 30 | $preview = $args['preview'] ?? null; 31 | 32 | if( $preview instanceof UploadedFile ) { 33 | $file->addPreviews( $preview ); 34 | } elseif( $upload instanceof UploadedFile ) { 35 | $file->addPreviews( $upload ); 36 | } elseif( $preview === false ) { 37 | $file->previews = []; 38 | } 39 | } 40 | catch( \Throwable $t ) 41 | { 42 | $file->removePreviews()->removeFile(); 43 | throw $t; 44 | } 45 | 46 | $editor = Auth::user()?->name ?? request()->ip(); 47 | $file->versions()->create( [ 48 | 'editor' => $editor, 49 | 'data' => [ 50 | 'tag' => $file->tag, 51 | 'name' => $file->name, 52 | 'mime' => $file->mime, 53 | 'path' => $file->path, 54 | 'previews' => $file->previews, 55 | 'description' => $file->description, 56 | ], 57 | ] ); 58 | 59 | $file->removeVersions(); 60 | 61 | return $orig; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/GraphQL/Mutations/SavePage.php: -------------------------------------------------------------------------------- 1 | findOrFail( $args['id'] ); 21 | 22 | DB::connection( config( 'cms.db', 'sqlite' ) )->transaction( function() use ( $page, $args ) { 23 | 24 | $data = $args['input'] ?? []; 25 | unset( $data['contents'] ); 26 | 27 | $version = $page->versions()->create([ 28 | 'editor' => Auth::user()?->name ?? request()->ip(), 29 | 'contents' => $args['input']['contents'] ?? null, 30 | 'lang' => $args['input']['lang'] ?? null, 31 | 'data' => $data, 32 | ]); 33 | 34 | $version->elements()->sync( $args['elements'] ?? [] ); 35 | $version->files()->sync( $args['files'] ?? [] ); 36 | 37 | // MySQL doesn't support offsets for DELETE 38 | $ids = Version::where( 'versionable_id', $page->id ) 39 | ->where( 'versionable_type', Page::class ) 40 | ->orderBy( 'id', 'desc' ) 41 | ->skip( 10 ) 42 | ->take( 10 ) 43 | ->pluck( 'id' ); 44 | 45 | if( !$ids->isEmpty() ) { 46 | Version::whereIn( 'id', $ids )->forceDelete(); 47 | } 48 | 49 | }, 3 ); 50 | 51 | return $page; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Http/Controllers/PageController.php: -------------------------------------------------------------------------------- 1 | input( 'preview' ) && Gate::allowIf( fn( $user ) => $user->cmseditor > 0 ) ) 42 | { 43 | $page = Page::where( 'slug', $slug ) 44 | ->where( 'domain', $domain ) 45 | ->where( 'lang', $lang ) 46 | ->firstOrFail(); 47 | 48 | $page->fill( $page->latest()->data ); 49 | $page->cache = 0; // don't cache sub-parts in preview requests 50 | 51 | if( $page->to ) { 52 | return !str_starts_with( $page->to, 'http' ) ? redirect( $page->to ) : redirect()->away( $page->to ); 53 | } 54 | 55 | return view( config( 'cms.view', 'cms::page' ), ['page' => $page] )->render(); 56 | } 57 | 58 | $cache = Cache::store( config( 'cms.cache', 'file' ) ); 59 | $key = Page::key( $slug, $lang, $domain ); 60 | 61 | if( $html = $cache->get( $key ) ) { 62 | return $html; 63 | } 64 | 65 | $page = Page::where( 'slug', $slug ) 66 | ->where( 'domain', $domain ) 67 | ->where( 'lang', $lang ) 68 | ->where( 'status', '>', 0 ) 69 | ->firstOrFail(); 70 | 71 | if( $page->to ) { 72 | return !str_starts_with( $page->to, 'http' ) ? redirect( $page->to ) : redirect()->away( $page->to ); 73 | } 74 | 75 | $html = view( config( 'cms.view', 'cms::page' ), ['page' => $page] )->render(); 76 | 77 | if( $page->cache ) { 78 | $cache->put( $key, $html, now()->addMinutes( (int) $page->cache ) ); 79 | } 80 | 81 | return $html; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/JsonApi/V1/Controllers/JsonapiController.php: -------------------------------------------------------------------------------- 1 | withMeta( ['baseurl' => Storage::disk( config( 'cms.disk', 'public' ) )->url( '' )] ) 32 | ->withQueryParameters( $query ); 33 | } 34 | 35 | 36 | /** 37 | * Adds global meta data to related resource response. 38 | * 39 | * @param Page|null $page Page model 40 | * @param mixed $data Fetched data 41 | * @param ResourceQuery $request Query object 42 | * @param Relation Relation type object 43 | * @return RelatedResponse 44 | */ 45 | public function readRelatedElements( ?Page $page, $data, ResourceQuery $request ) : RelatedResponse 46 | { 47 | return RelatedResponse::make( $page, 'elements', $data ) 48 | ->withMeta( ['baseurl' => Storage::disk( config( 'cms.disk', 'public' ) )->url( '' )] ) 49 | ->withQueryParameters( $request ); 50 | } 51 | 52 | 53 | /** 54 | * Adds global meta data to collection resource response. 55 | * 56 | * @param mixed $data Fetched data 57 | * @param PageCollectionQuery $query Page collection query 58 | * @return DataResponse 59 | */ 60 | public function searched( $data, PageCollectionQuery $query ) : DataResponse 61 | { 62 | return DataResponse::make( $data ) 63 | ->withMeta( ['baseurl' => Storage::disk( config( 'cms.disk', 'public' ) )->url( '' )] ) 64 | ->withQueryParameters( $query ); 65 | } 66 | } -------------------------------------------------------------------------------- /src/JsonApi/V1/Elements/ElementSchema.php: -------------------------------------------------------------------------------- 1 | 1]; 23 | 24 | /** 25 | * Disables "self" links for element items. 26 | */ 27 | protected bool $selfLink = false; 28 | 29 | /** 30 | * The model the schema corresponds to. 31 | * 32 | * @var string 33 | */ 34 | public static string $model = Element::class; 35 | 36 | 37 | /** 38 | * Determine if the resource is authorizable. 39 | * 40 | * @return bool 41 | */ 42 | public function authorizable(): bool 43 | { 44 | return false; 45 | } 46 | 47 | 48 | /** 49 | * Get the resource fields. 50 | * 51 | * @return array 52 | */ 53 | public function fields(): array 54 | { 55 | return [ 56 | ID::make(), 57 | Str::make( 'type' )->readOnly(), 58 | Str::make( 'lang' )->readOnly(), 59 | ArrayHash::make( 'data' )->readOnly(), 60 | DateTime::make( 'createdAt' )->readOnly(), 61 | ]; 62 | } 63 | 64 | 65 | /** 66 | * Get the resource filters. 67 | * 68 | * @return array 69 | */ 70 | public function filters(): array 71 | { 72 | return []; 73 | } 74 | 75 | 76 | /** 77 | * Get the resource paginator. 78 | * 79 | * @return Paginator|null 80 | */ 81 | public function pagination(): ?Paginator 82 | { 83 | return PagePagination::make()->withDefaultPerPage( 50 ); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/JsonApi/V1/Pages/PageCollectionQuery.php: -------------------------------------------------------------------------------- 1 | [ 20 | 'nullable', 21 | 'array', 22 | JsonApiRule::fieldSets(), 23 | ], 24 | 'filter' => [ 25 | 'nullable', 26 | 'array', 27 | JsonApiRule::filter(), 28 | ], 29 | 'include' => [ 30 | 'nullable', 31 | 'string', 32 | JsonApiRule::includePaths()->forget( 33 | 'children.children', 34 | ), 35 | ], 36 | 'page' => [ 37 | 'nullable', 38 | 'array', 39 | JsonApiRule::page(), 40 | ], 41 | 'page.number' => [ 42 | 'integer', 43 | 'between:1,100', 44 | ], 45 | 'page.size' => [ 46 | 'integer', 47 | 'between:1,100', 48 | ], 49 | 'sort' => JsonApiRule::notSupported(), 50 | ]; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/JsonApi/V1/Pages/PageQuery.php: -------------------------------------------------------------------------------- 1 | [ 20 | 'nullable', 21 | 'array', 22 | JsonApiRule::fieldSets(), 23 | ], 24 | 'filter' => [ 25 | 'nullable', 26 | 'array', 27 | JsonApiRule::filter(), 28 | ], 29 | 'include' => [ 30 | 'nullable', 31 | 'string', 32 | JsonApiRule::includePaths()->forget( 33 | 'children.children', 34 | ), 35 | ], 36 | 'page' => JsonApiRule::notSupported(), 37 | 'sort' => JsonApiRule::notSupported(), 38 | ]; 39 | } 40 | } -------------------------------------------------------------------------------- /src/JsonApi/V1/Server.php: -------------------------------------------------------------------------------- 1 | baseUri ) ) { 58 | $this->baseUri = Route::has( 'cms.pages' ) ? str_replace( '/pages', '', Url::route( 'cms.pages' ) ) : '/cms'; 59 | } 60 | 61 | return $this->baseUri; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Models/Version.php: -------------------------------------------------------------------------------- 1 | '', 34 | 'lang' => '', 35 | 'data' => '{}', 36 | 'contents' => null, 37 | 'publish_at' => null, 38 | 'published' => false, 39 | 'editor' => '', 40 | ]; 41 | 42 | /** 43 | * The automatic casts for the attributes. 44 | * 45 | * @var array 46 | */ 47 | protected $casts = [ 48 | 'lang' => 'string', 49 | 'data' => 'object', 50 | 'contents' => 'array', 51 | ]; 52 | 53 | /** 54 | * The attributes that are mass assignable. 55 | * 56 | * @var array 57 | */ 58 | protected $fillable = [ 59 | 'publish_at', 60 | 'contents', 61 | 'editor', 62 | 'data', 63 | 'lang', 64 | ]; 65 | 66 | /** 67 | * The table associated with the model. 68 | * 69 | * @var string 70 | */ 71 | protected $table = 'cms_versions'; 72 | 73 | 74 | /** 75 | * Get the shared element attached to the version. 76 | */ 77 | public function elements() : BelongsToMany 78 | { 79 | return $this->belongsToMany( Element::class, 'cms_version_element' ); 80 | } 81 | 82 | 83 | /** 84 | * Get all files referenced by the versioned data. 85 | */ 86 | public function files() : BelongsToMany 87 | { 88 | return $this->belongsToMany( File::class, 'cms_version_file' ); 89 | } 90 | 91 | 92 | /** 93 | * Get the connection name for the model. 94 | */ 95 | public function getConnectionName() 96 | { 97 | return config( 'cms.db', 'sqlite' ); 98 | } 99 | 100 | 101 | /** 102 | * Disables using the updated_at column. 103 | * Versions are never updated, each one is created as a new entry. 104 | */ 105 | public function getUpdatedAtColumn() 106 | { 107 | return null; 108 | } 109 | 110 | 111 | /** 112 | * Get the parent versionable model (page, file or element). 113 | */ 114 | public function versionable() : MorphTo 115 | { 116 | return $this->morphTo(); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/Permission.php: -------------------------------------------------------------------------------- 1 | 0b00000000000000000000000000000001, 23 | 'page:save' => 0b00000000000000000000000000000010, 24 | 'page:add' => 0b00000000000000000000000000000100, 25 | 'page:drop' => 0b00000000000000000000000000001000, 26 | 'page:keep' => 0b00000000000000000000000000010000, 27 | 'page:purge' => 0b00000000000000000000000000100000, 28 | 'page:publish' => 0b00000000000000000000000001000000, 29 | 'page:move' => 0b00000000000000000000000010000000, 30 | 31 | 'element:view' => 0b00000000000000000000000100000000, 32 | 'element:save' => 0b00000000000000000000001000000000, 33 | 'element:add' => 0b00000000000000000000010000000000, 34 | 'element:drop' => 0b00000000000000000000100000000000, 35 | 'element:keep' => 0b00000000000000000001000000000000, 36 | 'element:purge' => 0b00000000000000000010000000000000, 37 | 'element:publish' => 0b00000000000000000100000000000000, 38 | 39 | 'file:view' => 0b00000000000000010000000000000000, 40 | 'file:save' => 0b00000000000000100000000000000000, 41 | 'file:add' => 0b00000000000001000000000000000000, 42 | 'file:drop' => 0b00000000000010000000000000000000, 43 | 'file:keep' => 0b00000000000100000000000000000000, 44 | 'file:purge' => 0b00000000001000000000000000000000, 45 | 'file:publish' => 0b00000000010000000000000000000000, 46 | ]; 47 | 48 | /** 49 | * Anonymous callback which allows or denies actions. 50 | */ 51 | public static ?\Closure $callback = null; 52 | 53 | 54 | /** 55 | * Checks if the user has the permission for the requested action. 56 | * 57 | * @param string action Name of the requested action, e.g. "page:view" 58 | * @param \App\Models\User|null $user Laravel user object 59 | * @return bool TRUE of the user is allowed to perform the action, FALSE if not 60 | */ 61 | public static function can( string $action, ?User $user ) : bool 62 | { 63 | if( $closure = self::$callback ) { 64 | return $closure( $action, $user ); 65 | } 66 | 67 | if( $action === '*' ) { 68 | return $user && $user->cmseditor > 0; 69 | } 70 | 71 | return $user && isset( self::$can[$action] ) && self::$can[$action] & $user->cmseditor; 72 | } 73 | 74 | 75 | /** 76 | * Returns the available actions and their permissions. 77 | * 78 | * @param \App\Models\User|null $user Laravel user object 79 | * @return array List of actions as keys and booleans as values indicating if the user has permission for the action 80 | */ 81 | public static function get( ?User $user ) : array 82 | { 83 | $map = []; 84 | 85 | foreach( self::$can as $action => $bit ) { 86 | $map[$action] = self::can( $action, $user ); 87 | } 88 | 89 | return $map; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Policies/ElementPolicy.php: -------------------------------------------------------------------------------- 1 | cmseditor ) ) { 30 | $builder->where( $model->qualifyColumn( 'status' ), '>', 0 ); 31 | } 32 | } 33 | 34 | 35 | /** 36 | * Adds additional macros to the query builder. 37 | * 38 | * @param \Illuminate\Database\Eloquent\Builder $builder Query builder 39 | */ 40 | public function extend( Builder $builder ) 41 | { 42 | $builder->macro( 'withoutStatus', function( Builder $builder ) { 43 | return $builder->withoutGlobalScope( $this ); 44 | }); 45 | } 46 | } -------------------------------------------------------------------------------- /src/Scopes/Tenancy.php: -------------------------------------------------------------------------------- 1 | where( $model->qualifyColumn( $model->getTenantColumn() ), \Aimeos\Cms\Tenancy::value() ); 29 | } 30 | 31 | 32 | /** 33 | * Adds additional macros to the query builder. 34 | * 35 | * @param \Illuminate\Database\Eloquent\Builder $builder Query builder 36 | */ 37 | public function extend( Builder $builder ) 38 | { 39 | $builder->macro( 'withoutTenancy', function( Builder $builder ) { 40 | return $builder->withoutGlobalScope( $this ); 41 | }); 42 | } 43 | } -------------------------------------------------------------------------------- /src/Scopes/Timeframe.php: -------------------------------------------------------------------------------- 1 | where( function( Builder $query ) use ( $model ) { 33 | return $query->where( $model->qualifyColumn( 'start' ), null ) 34 | ->orWhere( $model->qualifyColumn( 'start' ), '>=', date( 'Y-m-d H:i:00' ) ); 35 | } ) 36 | ->where( function( Builder $query ) use ( $model ) { 37 | return $query->where( $model->qualifyColumn( 'end' ), null ) 38 | ->orWhere( $model->qualifyColumn( 'end' ), '<=', date( 'Y-m-d H:i:00' ) ); 39 | } ); 40 | } 41 | } 42 | 43 | 44 | /** 45 | * Adds additional macros to the query builder. 46 | * 47 | * @param \Illuminate\Database\Eloquent\Builder $builder Query builder 48 | */ 49 | public function extend( Builder $builder ) 50 | { 51 | $builder->macro( 'withoutTimeframe', function( Builder $builder ) { 52 | return $builder->withoutGlobalScope( $this ); 53 | }); 54 | } 55 | } -------------------------------------------------------------------------------- /src/Tenancy.php: -------------------------------------------------------------------------------- 1 | $path ) { 15 | $list[] = cmsurl( $path ) . ' ' . $width . 'w'; 16 | } 17 | 18 | return implode( ',', $list ); 19 | } 20 | } 21 | 22 | 23 | if( !function_exists( 'cmsurl' ) ) 24 | { 25 | function cmsurl( string $path ): string 26 | { 27 | if( \Illuminate\Support\Str::startsWith( $path, ['data:', 'http:', 'https:'] ) ) { 28 | return $path; 29 | } 30 | 31 | return \Illuminate\Support\Facades\Storage::disk( config( 'cms.disk', 'public' ) )->url( $path ); 32 | } 33 | } 34 | 35 | 36 | if( !function_exists( 'cmsasset' ) ) 37 | { 38 | function cmsasset( string $path ): string 39 | { 40 | return asset( $path ) . '?v=' . filemtime( public_path( $path ) ); 41 | } 42 | } -------------------------------------------------------------------------------- /src/server.php: -------------------------------------------------------------------------------- 1 | seed( CmsSeeder::class ); 16 | 17 | $this->artisan('cms:publish')->assertExitCode( 0 ); 18 | 19 | $this->assertEquals( 1, Page::where( 'slug', 'hidden' )->firstOrFail()?->status ); 20 | $this->assertEquals( 'Powered by Laravel CMS!', Element::where( 'name', 'Shared footer' )->firstOrFail()?->data->text ); 21 | $this->assertEquals( (object) [ 22 | 'en' => 'Test file description', 23 | 'de' => 'Beschreibung der Testdatei', 24 | ], File::where( 'tag', 'test' )->firstOrFail()?->description ); 25 | } 26 | } -------------------------------------------------------------------------------- /tests/GraphqlAuthTest.php: -------------------------------------------------------------------------------- 1 | set( 'lighthouse.schema_path', __DIR__ . '/default-schema.graphql' ); 20 | $app['config']->set( 'lighthouse.namespaces.models', ['App\Models', 'Aimeos\\Cms\\Models'] ); 21 | $app['config']->set( 'lighthouse.namespaces.mutations', ['Aimeos\\Cms\\GraphQL\\Mutations'] ); 22 | } 23 | 24 | 25 | protected function getPackageProviders( $app ) 26 | { 27 | return array_merge( parent::getPackageProviders( $app ), [ 28 | 'Nuwave\Lighthouse\LighthouseServiceProvider' 29 | ] ); 30 | } 31 | 32 | 33 | protected function setUp(): void 34 | { 35 | parent::setUp(); 36 | $this->bootRefreshesSchemaCache(); 37 | 38 | $this->user = \App\Models\User::create([ 39 | 'name' => 'Test', 40 | 'email' => 'editor@testbench', 41 | 'password' => \Illuminate\Support\Facades\Hash::make('secret'), 42 | 'cmseditor' => 1 43 | ]); 44 | } 45 | 46 | 47 | public function testLogin() 48 | { 49 | $user = \App\Models\User::where('email', 'editor@testbench')->firstOrFail(); 50 | 51 | $attr = collect($user->getAttributes())->except(['cmseditor', 'password', 'secret', 'remember_token'])->all(); 52 | $expected = ['id' => (string) $user->id] + $attr; 53 | 54 | $this->expectsDatabaseQueryCount( 1 ); 55 | 56 | $response = $this->graphQL( " 57 | mutation { 58 | cmsLogin(email: \"editor@testbench\", password: \"secret\") { 59 | id 60 | email 61 | name 62 | email_verified_at 63 | created_at 64 | updated_at 65 | } 66 | } 67 | " )->assertJson( [ 68 | 'data' => [ 69 | 'cmsLogin' => $expected, 70 | ] 71 | ] ); 72 | } 73 | 74 | 75 | public function testLogout() 76 | { 77 | $user = \App\Models\User::where('email', 'editor@testbench')->firstOrFail(); 78 | 79 | $attr = collect($user->getAttributes())->except(['cmseditor', 'password', 'secret', 'remember_token'])->all(); 80 | $expected = ['id' => (string) $user->id] + $attr; 81 | 82 | $this->expectsDatabaseQueryCount( 0 ); 83 | 84 | $response = $this->actingAs( $this->user )->graphQL( " 85 | mutation { 86 | cmsLogout { 87 | id 88 | email 89 | name 90 | email_verified_at 91 | created_at 92 | updated_at 93 | } 94 | } 95 | " )->assertJson( [ 96 | 'data' => [ 97 | 'cmsLogout' => $expected, 98 | ] 99 | ] ); 100 | } 101 | 102 | 103 | public function testMe() 104 | { 105 | $user = \App\Models\User::where('email', 'editor@testbench')->firstOrFail(); 106 | 107 | $attr = collect($user->getAttributes())->except(['cmseditor', 'password', 'secret', 'remember_token'])->all(); 108 | $expected = ['id' => (string) $user->id] + $attr; 109 | 110 | $this->expectsDatabaseQueryCount( 0 ); 111 | 112 | $response = $this->actingAs( $this->user )->graphQL( "{ 113 | me { 114 | id 115 | email 116 | name 117 | email_verified_at 118 | created_at 119 | updated_at 120 | } 121 | }" )->assertJson( [ 122 | 'data' => [ 123 | 'me' => $expected, 124 | ] 125 | ] ); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /tests/HelpersTest.php: -------------------------------------------------------------------------------- 1 | assertEquals( '/storage/not/exists.jpg 1w', cmsimage( [1 => 'not/exists.jpg'] ) ); 11 | } 12 | 13 | 14 | public function testCmsUrl() 15 | { 16 | $this->assertEquals( 'data:ABCD', cmsurl( 'data:ABCD' ) ); 17 | $this->assertEquals( '/storage/not/exists.jpg', cmsurl( 'not/exists.jpg' ) ); 18 | $this->assertEquals( 'http://example.com/not/exists.jpg', cmsurl( 'http://example.com/not/exists.jpg' ) ); 19 | $this->assertEquals( 'https://example.com/not/exists.jpg', cmsurl( 'https://example.com/not/exists.jpg' ) ); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/TestAbstract.php: -------------------------------------------------------------------------------- 1 | loadLaravelMigrations(['--database' => 'testing']); 24 | } 25 | 26 | 27 | protected function defineEnvironment( $app ) 28 | { 29 | $app['config']->set('database.connections.testing', [ 30 | 'driver' => 'sqlite', 31 | 'database' => ':memory:', 32 | 'prefix' => 'test_', 33 | ]); 34 | 35 | $app['config']->set( 'cms.db', 'testing' ); 36 | 37 | \Aimeos\Cms\Tenancy::$callback = function() { 38 | return 'test'; 39 | }; 40 | } 41 | 42 | 43 | protected function getPackageProviders( $app ) 44 | { 45 | return [ 46 | 'Aimeos\Cms\CmsServiceProvider', 47 | 'Kalnoy\Nestedset\NestedSetServiceProvider', 48 | ]; 49 | } 50 | } -------------------------------------------------------------------------------- /tests/User.php: -------------------------------------------------------------------------------- 1 | '', 10 | 'email' => '', 11 | 'password' => '', 12 | 'cmseditor' => 0, 13 | ]; 14 | 15 | protected $fillable = [ 16 | 'name', 17 | 'email', 18 | 'password', 19 | 'cmseditor', 20 | ]; 21 | } 22 | -------------------------------------------------------------------------------- /tests/default-schema.graphql: -------------------------------------------------------------------------------- 1 | "A datetime string with format `Y-m-d H:i:s`, e.g. `2018-05-23 13:43:32`." 2 | scalar DateTime @scalar(class: "Nuwave\\Lighthouse\\Schema\\Types\\Scalars\\DateTime") 3 | 4 | "Indicates what fields are available at the top level of a query operation." 5 | type Query { 6 | "Find a single user by an identifying attribute." 7 | user( 8 | "Search by primary key." 9 | id: ID @eq @rules(apply: ["prohibits:email", "required_without:email"]) 10 | 11 | "Search by email address." 12 | email: String @eq @rules(apply: ["prohibits:id", "required_without:id", "email"]) 13 | ): User @find 14 | 15 | "List multiple users." 16 | users( 17 | "Filters by name. Accepts SQL LIKE wildcards `%` and `_`." 18 | name: String @where(operator: "like") 19 | ): [User!]! @paginate(defaultCount: 10) 20 | } 21 | 22 | "Account of a person who utilizes this application." 23 | type User { 24 | "Unique primary key." 25 | id: ID! 26 | 27 | "Non-unique name." 28 | name: String! 29 | 30 | "Unique email address." 31 | email: String! 32 | 33 | "When the email was verified." 34 | email_verified_at: DateTime 35 | 36 | "When the account was created." 37 | created_at: DateTime! 38 | 39 | "When the account was last updated." 40 | updated_at: DateTime! 41 | } 42 | 43 | #import ../graphql/cms.graphql -------------------------------------------------------------------------------- /views/admin.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | @if( !config('app.debug') ) 9 | 10 | @endif 11 | 12 | Laravel CMS Admin 13 | 14 | 15 | 16 | 17 | 18 |
26 | 27 | 28 | -------------------------------------------------------------------------------- /views/article.blade.php: -------------------------------------------------------------------------------- 1 | @pushOnce('css') 2 | 3 | @endPushOnce 4 | 5 |
6 |

{{ $title }}

7 | 8 | @includeIf('cms::image', ['main' => true] + ($intro ?? []) )) 9 | 10 |
11 | 'escape', 13 | 'allow_unsafe_links' => false, 14 | 'max_nesting_level' => 25 15 | ]))->convert($text ?? '') 16 | ?> 17 |
18 |
-------------------------------------------------------------------------------- /views/cards.blade.php: -------------------------------------------------------------------------------- 1 |
2 | @foreach($cards ?? [] as $card) 3 |
4 |
5 | {{ $card['title'] ?? '' }} 6 |
7 |
8 | 9 | @foreach( $card['image']['previews'] ?? [] as $width => $path ) 10 | 13 | @endforeach 14 | {{ $card['image']['name'] ?? '' }} 18 | 19 |
{{ $card['name'] ?? '' }}
20 |
21 | 'escape', 23 | 'allow_unsafe_links' => false, 24 | 'max_nesting_level' => 25 25 | ]))->convert($card['text'] ?? '') 26 | ?> 27 |
28 |
29 |
30 | @endforeach 31 |
-------------------------------------------------------------------------------- /views/code.blade.php: -------------------------------------------------------------------------------- 1 | @pushOnce('css') 2 | 3 | @endPushOnce 4 | 5 | @pushOnce('js') 6 | 7 | @endPushOnce 8 | 9 |

10 | {{ $text ?? '' }}
11 | 
-------------------------------------------------------------------------------- /views/heading.blade.php: -------------------------------------------------------------------------------- 1 | 2 | {{ $text ?? '' }} 3 | 4 | -------------------------------------------------------------------------------- /views/html.blade.php: -------------------------------------------------------------------------------- 1 |
2 | {!! $text ?? '' !!} 3 |
-------------------------------------------------------------------------------- /views/image-text.blade.php: -------------------------------------------------------------------------------- 1 |
2 | @includeIf('cms::image', $image ?? [] ) 3 |
4 | 'escape', 6 | 'allow_unsafe_links' => false, 7 | 'max_nesting_level' => 25 8 | ]))->convert($text ?? '') 9 | ?> 10 |
11 |
-------------------------------------------------------------------------------- /views/image.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | @foreach( $image['previews'] ?? [] as $width => $path ) 4 | 7 | @endforeach 8 | {{ $image['name'] ?? '' }} 12 | 13 | -------------------------------------------------------------------------------- /views/invalid.blade.php: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /views/paragraph.blade.php: -------------------------------------------------------------------------------- 1 |
2 | 'escape', 4 | 'allow_unsafe_links' => false, 5 | 'max_nesting_level' => 25 6 | ]))->convert($text ?? '') 7 | ?> 8 |
-------------------------------------------------------------------------------- /views/table.blade.php: -------------------------------------------------------------------------------- 1 | 2 | @if($title ?? null) 3 | 6 | @endif 7 | 8 | @foreach((array) str_getcsv($content ?? '') as $rowidx => $row) 9 | @foreach((array) $row as $colidx => $col) 10 | @if($rowidx === 0 && ($header ?? null) === 'row' 11 | || $colidx === 0 && ($header ?? null) === 'col' 12 | || ($rowidx === 0 || $colidx === 0) && ($header ?? null) === 'row+col') 13 | 33 |
4 | {{ $title ?? '' }} 5 |
14 | @else 15 | 16 | @endif 17 | 'escape', 19 | 'allow_unsafe_links' => false, 20 | 'max_nesting_level' => 25 21 | ]))->convert((string) $col) 22 | ?> 23 | @if($rowidx === 0 && ($header ?? null) === 'row' 24 | || $colidx === 0 && ($header ?? null) === 'col' 25 | || ($rowidx === 0 || $colidx === 0) && ($header ?? null) === 'row+col') 26 | 27 | @else 28 | 29 | @endif 30 | @endforeach 31 | @endforeach 32 |
2 | {{ __('Download') }}: {{ cmsurl( $source ?? '' ) }} 3 | --------------------------------------------------------------------------------