commit 21b5eaa7a0a83166027fdae12e47f8b107339fc3 Author: Xiaoxiaobai5724 Date: Fri Jul 31 00:13:44 2026 +0800 first commit diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..5916c10 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2019-2023 Clark Winkelmann + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..44c7b1b --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# Catch The Fish + +[![MIT license](https://img.shields.io/badge/license-MIT-blue)](https://github.com/Xiaoxiaobai5724/catch-the-fish/blob/master/LICENSE.md) [![Latest Stable Version](https://img.shields.io/packagist/v/xxb5724/catch-the-fish)](https://packagist.org/packages/xxb5724/catch-the-fish) [![Total Downloads](https://img.shields.io/packagist/dt/xxb5724/catch-the-fish)](https://packagist.org/packages/xxb5724/catch-the-fish) + +[![Extended state](https://floxum.com/extension/xxb5724/catch-the-fish/open-graph-image)](https://floxum.com/extension/xxb5724/catch-the-fish) + +A [Flarum](https://flarum.org) 2.0 extension. This extension is a fun experiment that adds a minigame to Flarum. + +Once enabled, admins or mods can create fish catching rounds. + +The fishes will appear on the forum and users compete to catch as many as they can. + +It includes many customization options. You can let users change the fish names or choose the placement of the fish themselves. + +All aspects of the game are controlled via Flarum permissions. + +The extension comes with a starting pack of (public domain image) fish that you can include when creating a new round. + +## Installation + +Installing with Composer: + +```sh +composer require xxb5724/catch-the-fish +``` + +> **Migrating from clarkwinkelmann/catch-the-fish** +> This extension was transferred to XXB and was previously published as `clarkwinkelmann/catch-the-fish`. The XXB line begins at **2.0.0**, continuing the version history (the previous package reached 1.1.4). +> For forum admins, migration is a one-line change — swap the package, keep your settings and stored SEO data: +```sh +composer remove clarkwinkelmann/catch-the-fish +composer require xxb5724/catch-the-fish +php flarum cache:clear +``` + +## Upgrade + +```sh +composer update xxb5724/catch-the-fish +php flarum cache:clear +``` + +## Configuration + +You will find the settings for this extension in 3 different places: + +- Extension settings page: global day-based, minute-based and animation settings, as well as probabilities for automatic fish placement and discussion tags whitelist +- Flarum permissions: all access related settings +- Front end page (not in admin): Round and fish configuration + +## New improvements + +The 'Fish Basket' feature from clarkwinkelmann/catch-the-fish wasn’t compatible with mobile, but now it is. Mobile users can choose where to store their fish after catching them. When you go back to the bottom of the homepage, there’s a 'Fish Basket.' Open any discussion or post, select a fish from the basket, and then click anywhere to place it. \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..04bd334 --- /dev/null +++ b/composer.json @@ -0,0 +1,53 @@ +{ + "name": "xxb5724/catch-the-fish", + "description": "Watch your users catch as many fishes as they can.", + "keywords": [ + "extension", + "flarum", + "minigame", + "april-fools" + ], + "type": "flarum-extension", + "license": "MIT", + "authors": [ + { + "name": "Clark Winkelmann", + "email": "clark.winkelmann@gmail.com", + "homepage": "https://clarkwinkelmann.com/" + }, + { + "name": "Xiaoxiaobai5724", + "email": "odoorbell@163.com", + "role": "Developer" + }, + ], + "support": { + "issues": "https://git.parlz.com/Xiaoxiaobai5724/catch-the-fish/issues", + "source": "https://git.parlz.com/Xiaoxiaobai5724/catch-the-fish" + }, + "require": { + "flarum/core": "^2.0", + "ext-json": "*" + }, + "extra": { + "flarum-extension": { + "title": "XXB Catch the fish", + "category": "feature", + "icon": { + "name": "fas fa-fish", + "backgroundColor": "#00BFFF", + "color": "#fff" + }, + "optional-dependencies": [ + "flarum/suspend", + "flarum/tags" + ] + } + }, + "autoload": { + "psr-4": { + "XXB\\CatchTheFish\\": "src/", + "Tobscure\\JsonApi\\": "src/Tobscure/JsonApi/" + } + } +} \ No newline at end of file diff --git a/extend.php b/extend.php new file mode 100644 index 0000000..8edba04 --- /dev/null +++ b/extend.php @@ -0,0 +1,116 @@ +css(__DIR__ . '/resources/less/forum.less') + ->js(__DIR__ . '/js/dist/forum.js') + ->route('/catch-the-fish', 'catch-the-fish-ranking') + ->route('/catch-the-fish/rounds', 'catch-the-fish-rounds') + ->route('/catch-the-fish/rounds/{id}', 'catch-the-fish-round'), + (new Extend\Frontend('admin')) + ->css(__DIR__ . '/resources/less/admin.less') + ->js(__DIR__ . '/js/dist/admin.js'), + + (new Extend\Routes('api')) + ->get('/catch-the-fish/rounds', 'catchthefish.api.rounds.index', Controllers\RoundIndexController::class) + ->post('/catch-the-fish/rounds', 'catchthefish.api.rounds.store', Controllers\RoundStoreController::class) + ->get('/catch-the-fish/rounds/{id:[0-9]+}', 'catchthefish.api.rounds.show', Controllers\RoundShowController::class) + ->patch('/catch-the-fish/rounds/{id:[0-9]+}', 'catchthefish.api.rounds.update', Controllers\RoundUpdateController::class) + ->delete('/catch-the-fish/rounds/{id:[0-9]+}', 'catchthefish.api.rounds.delete', Controllers\RoundDeleteController::class) + ->get('/catch-the-fish/rounds/{id:[0-9]+}/fishes', 'catchthefish.api.fishes.index', Controllers\FishIndexController::class) + ->post('/catch-the-fish/rounds/{id:[0-9]+}/fishes', 'catchthefish.api.fishes.store', Controllers\FishStoreController::class) + ->post('/catch-the-fish/rounds/{id:[0-9]+}/fishes-from-images', 'catchthefish.api.fishes.store-image', Controllers\FishImageBulkController::class) + ->patch('/catch-the-fish/fishes/{id:[0-9]+}', 'catchthefish.api.fishes.update', Controllers\FishUpdateController::class) + ->delete('/catch-the-fish/fishes/{id:[0-9]+}', 'catchthefish.api.fishes.delete', Controllers\FishDeleteController::class) + ->post('/catch-the-fish/fishes/{id:[0-9]+}/catch', 'catchthefish.api.fishes.catch', Controllers\FishCatchController::class) + ->post('/catch-the-fish/fishes/{id:[0-9]+}/place', 'catchthefish.api.fishes.place', Controllers\FishPlaceController::class) + ->post('/catch-the-fish/fishes/{id:[0-9]+}/image', 'catchthefish.api.fishes.image', Controllers\FishImageController::class) + ->get('/catch-the-fish/rounds/{id:[0-9]+}/rankings', 'catchthefish.api.rankings.index', Controllers\RankingIndexController::class), + + (new Extend\Locales(__DIR__ . '/resources/locale')), + + new Extend\ApiResource(RoundResource::class), + new Extend\ApiResource(FishResource::class), + (new Extend\ApiResource(Resource\ForumResource::class)) + ->fields(fn() => [ + Schema\Boolean::make('catchTheFishCanModerate') + ->get(fn(object $forum, Context $context) => $context->getActor()->isAdmin() || $context->getActor()->can('catchthefish.moderate')), + Schema\Boolean::make('catchTheFishCanSeeRankingsPage') + ->get(fn(object $forum, Context $context) => $context->getActor()->isAdmin() || $context->getActor()->can('catchthefish.list-rankings')), + Schema\Boolean::make('catchTheFishAlertRound') + ->get(fn(object $forum, Context $context) => resolve(SettingsRepositoryInterface::class)->get('catch-the-fish.alertRound') !== '0'), + Schema\Boolean::make('catchTheFishAnimateFlip') + ->get(fn(object $forum, Context $context) => resolve(SettingsRepositoryInterface::class)->get('catch-the-fish.animateFlip') !== '0'), + Schema\Relationship\ToMany::make('catchTheFishActiveRounds') + ->type('catchthefish-rounds') + ->includable() + ->get(fn(object $forum, Context $context) => ($context->getActor()->isAdmin() || $context->getActor()->can('catchthefish.visible')) ? Round::activeRound()->get()->all() : []), + ]) + ->endpoint(Endpoint\Show::class, fn(Endpoint\Show $endpoint) => $endpoint->addDefaultInclude(['catchTheFishActiveRounds'])), + (new Extend\ApiResource(Resource\DiscussionResource::class)) + ->fields(fn() => [ + Schema\Relationship\ToMany::make('catchTheFishFishes') + ->type('catchthefish-fishes') + ->includable() + ->get(fn(Discussion $discussion, Context $context) => ($context->getActor()->isAdmin() || $context->getActor()->can('catchthefish.visible')) ? $discussion->catchTheFishFishes()->activeFish()->get()->all() : []), + ]) + ->endpoint([Endpoint\Index::class, Endpoint\Show::class], fn(Endpoint\Index|Endpoint\Show $endpoint) => $endpoint->addDefaultInclude(['catchTheFishFishes'])), + (new Extend\ApiResource(Resource\PostResource::class)) + ->fields(fn() => [ + Schema\Relationship\ToMany::make('catchTheFishFishes') + ->type('catchthefish-fishes') + ->includable() + ->get(fn(Post $post, Context $context) => ($context->getActor()->isAdmin() || $context->getActor()->can('catchthefish.visible')) ? $post->catchTheFishFishes()->activeFish()->get()->all() : []), + ]) + ->endpoint([Endpoint\Index::class, Endpoint\Show::class], fn(Endpoint\Index|Endpoint\Show $endpoint) => $endpoint->addDefaultInclude(['catchTheFishFishes'])), + (new Extend\ApiResource(Resource\UserResource::class)) + ->fields(fn() => [ + Schema\Relationship\ToMany::make('catchTheFishFishes') + ->type('catchthefish-fishes') + ->includable() + ->get(fn(User $user, Context $context) => ($context->getActor()->isAdmin() || $context->getActor()->can('catchthefish.visible')) ? $user->catchTheFishFishes()->activeFish()->get()->all() : []), + Schema\Relationship\ToMany::make('catchTheFishBasket') + ->type('catchthefish-fishes') + ->includable() + ->get(fn(User $user, Context $context) => $context->getActor()->id === $user->id ? $user->catchTheFishBasket()->get()->all() : []), + ]) + ->endpoint([Endpoint\Index::class, Endpoint\Show::class], fn(Endpoint\Index|Endpoint\Show $endpoint) => $endpoint->addDefaultInclude(['catchTheFishFishes', 'catchTheFishBasket'])), + + (new Extend\Model(Discussion::class)) + ->relationship('catchTheFishFishes', function ($model) { + return (new ConfigureFishesRelationship('discussion_id_placement'))($model); + }), + (new Extend\Model(Post::class)) + ->relationship('catchTheFishFishes', function ($model) { + return (new ConfigureFishesRelationship('post_id_placement'))($model); + }), + (new Extend\Model(User::class)) + ->relationship('catchTheFishFishes', function ($model) { + return (new ConfigureFishesRelationship('user_id_placement'))($model); + }) + ->relationship('catchTheFishBasket', function ($model) { + return (new ConfigureBasketRelationship())($model); + }), + + (new Extend\Policy()) + ->modelPolicy(Fish::class, Access\FishPolicy::class) + ->modelPolicy(Round::class, Access\RoundPolicy::class), + + (new Extend\ServiceProvider()) + ->register(Providers\StorageServiceProvider::class), +]; diff --git a/js/dist/admin.js b/js/dist/admin.js new file mode 100644 index 0000000..dae897a --- /dev/null +++ b/js/dist/admin.js @@ -0,0 +1,2 @@ +(()=>{const t={n:e=>{const s=e&&e.__esModule?()=>e.default:()=>e;return t.d(s,{a:s}),s},d:(e,s)=>{if(Array.isArray(s))for(var n=0;nObject.prototype.hasOwnProperty.call(t,e),r:t=>{Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})}};let e={};(()=>{"use strict";t.r(e),t.d(e,{extend:()=>F});const s=flarum.reg.get("core","admin/app");var n=t.n(s);const a=flarum.reg.get("core","common/extenders");var i=t.n(a);function o(t){return o="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},o(t)}function r(t,e,s){return(e=function(t){var e=function(t){if("object"!=o(t)||!t)return t;var e=t[Symbol.toPrimitive];if(void 0!==e){var s=e.call(t,"string");if("object"!=o(s))return s;throw new TypeError("@@toPrimitive must return a primitive value.")}return String(t)}(t);return"symbol"==o(e)?e:e+""}(e))in t?Object.defineProperty(t,e,{value:s,enumerable:!0,configurable:!0,writable:!0}):t[e]=s,t}const l=flarum.reg.get("core","common/components/Button");var c=t.n(l);const p=flarum.reg.get("core","common/components/LoadingIndicator");var g=t.n(p);const u=flarum.reg.get("core","common/components/Select");var h=t.n(u);const d=flarum.reg.get("core","common/components/Switch");var b=t.n(d);const f=flarum.reg.get("core","admin/components/ExtensionPage");var y=t.n(f);const v="catch-the-fish.",T="xxb-catch-the-fish.admin.settings.";class x extends(y()){constructor(){super(...arguments),r(this,"allTagsLoaded",!1),r(this,"loadingAllTags",!1),r(this,"tagsAvailable",!0),r(this,"selectedTag","0")}oninit(t){super.oninit(t),this.loadAllTags()}loadAllTags(){this.loadingAllTags=!0,n().store.find("tags",{include:"parent,lastPostedDiscussion"}).then(()=>{this.tagsAvailable=!0,this.allTagsLoaded=!0}).catch(()=>{this.tagsAvailable=!1}).finally(()=>{this.loadingAllTags=!1,m.redraw()})}content(){return m(".ExtensionPage-settings",m(".container",[m("h3",n().translator.trans(T+"user")),m(".Form-group",[m("label",n().translator.trans(T+"user-age")),m("input.FormControl",{type:"number",min:1,step:1,bidi:this.setting(v+"userAgeDays","14")})]),m(".Form-group",[m("label",n().translator.trans(T+"user-probability")),m("input.FormControl.CTFFormControl--range",{type:"number",min:0,max:100,step:1,bidi:this.setting(v+"userProbability","33")}),m("input",{type:"range",min:0,max:100,step:1,bidi:this.setting(v+"userProbability","33")})]),m("h3",n().translator.trans(T+"discussion")),m(".Form-group",[m("label",n().translator.trans(T+"discussion-age")),m("input.FormControl",{type:"number",min:1,step:1,bidi:this.setting(v+"discussionAgeDays","14")})]),m(".Form-group",[m("label",n().translator.trans(T+"discussion-tags")),m("p.helpText",n().translator.trans(T+"discussion-tags-help")),this.tagSettings()]),m("h3",n().translator.trans(T+"post")),m(".Form-group",[m("label",n().translator.trans(T+"post-age")),m("input.FormControl",{type:"number",min:1,step:1,bidi:this.setting(v+"postAgeDays","14")})]),m(".Form-group",[m("label",n().translator.trans(T+"post-probability")),m("input.FormControl.CTFFormControl--range",{type:"number",min:0,max:100,step:1,bidi:this.setting(v+"postProbability","50")}),m("input",{type:"range",min:0,max:100,step:1,bidi:this.setting(v+"postProbability","50")})]),m("h3",n().translator.trans(T+"general")),m(".Form-group",[m("label",n().translator.trans(T+"time-to-place")),m("input.FormControl",{type:"number",min:1,step:1,bidi:this.setting(v+"autoPlacedAfterMinutes","5")})]),m(".Form-group",[b().component({state:"1"===this.setting(v+"alertRound","1")(),onchange:t=>{this.setting(v+"alertRound")(t?"1":"0")}},n().translator.trans(T+"alert-round"))]),m(".Form-group",[b().component({state:"1"===this.setting(v+"animateFlip","1")(),onchange:t=>{this.setting(v+"animateFlip")(t?"1":"0")}},n().translator.trans(T+"animate-flip"))]),m(".Form-group",this.submitButton())]))}tagSettings(){if(!this.tagsAvailable)return m("p",m("em",n().translator.trans(T+"discussion-tags-unavailable")));if(this.loadingAllTags)return g().component();let t=[];try{t=JSON.parse(this.setting(v+"discussionTags")())}catch(t){}Array.isArray(t)||(t=[]);let e={0:n().translator.trans(T+"tags-control.choose")};return n().store.all("tags").sort((t,e)=>{const s="function"==typeof t.position?t.position():null,n="function"==typeof e.position?e.position():null;return null!==s&&null!==n&&s!==n?s-n:String(t.name()).localeCompare(String(e.name()))}).forEach(s=>{t.some(t=>t.tagId===s.id())||(e[s.id()]=s.name())}),m("table.CTFTagsTable",[m("thead",m("tr",[m("th",n().translator.trans(T+"tags-header.tag")),m("th",n().translator.trans(T+"tags-header.probability")),m("th")])),m("tbody",[t.map((e,s)=>{const a=s=>{e.probability=parseInt(s.target.value),this.setting(v+"discussionTags")(JSON.stringify(t))},i=n().store.getById("tags",e.tagId),o=s===t.length-1;return m("tr",[m("td",i?i.name():m("em",e.tagId)),m("td",o?m("em",n().translator.trans(T+"tags-fallback."+(t.length>1?"last-line":"single-line"))):[m("input.FormControl.CTFFormControl--range",{type:"number",min:0,max:100,step:1,value:e.probability||"",onchange:a}),m("input",{type:"range",min:0,max:100,step:1,value:e.probability||"",onchange:a})]),m("td",[" ",c().component({className:"Button Button--icon",icon:"fas fa-chevron-down",onclick:()=>{t.splice(s+1,0,...t.splice(s,1)),this.setting(v+"discussionTags")(JSON.stringify(t))},disabled:o,title:n().translator.trans(T+"tags-control.down")})," ",c().component({className:"Button Button--icon",icon:"fas fa-chevron-up",onclick:()=>{t.splice(s-1,0,...t.splice(s,1)),this.setting(v+"discussionTags")(JSON.stringify(t))},disabled:0===s,title:n().translator.trans(T+"tags-control.up")})," ",c().component({className:"Button Button--icon",icon:"fas fa-times",onclick:()=>{t.splice(s,1),this.setting(v+"discussionTags")(JSON.stringify(t))},title:n().translator.trans(T+"tags-control.delete")})])])}),m("tr",[m("td",{colspan:2},h().component({options:e,value:this.selectedTag,onchange:t=>{this.selectedTag=t}})),m("td",c().component({className:"Button Button--block",onclick:()=>{t.push({tagId:this.selectedTag,probability:50}),this.setting(v+"discussionTags")(JSON.stringify(t)),this.selectedTag="0"},disabled:"0"===this.selectedTag},n().translator.trans(T+"tags-control.add")))])])])}}const F=[(new(i().Admin)).page(x).permission(()=>({icon:"fas fa-fish",label:n().translator.trans("xxb-catch-the-fish.admin.permissions.visible",{},!0),permission:"catchthefish.visible"}),"view",90).permission(()=>({icon:"fas fa-fish",label:n().translator.trans("xxb-catch-the-fish.admin.permissions.list-rankings",{},!0),permission:"catchthefish.list-rankings"}),"view",80).permission(()=>({icon:"fas fa-fish",label:n().translator.trans("xxb-catch-the-fish.admin.permissions.participate",{},!0),permission:"catchthefish.participate"}),"reply",90).permission(()=>({icon:"fas fa-fish",label:n().translator.trans("xxb-catch-the-fish.admin.permissions.choose-place",{},!0),permission:"catchthefish.choose-place"}),"reply",80).permission(()=>({icon:"fas fa-fish",label:n().translator.trans("xxb-catch-the-fish.admin.permissions.choose-name",{},!0),permission:"catchthefish.choose-name"}),"reply",70).permission(()=>({icon:"fas fa-fish",label:n().translator.trans("xxb-catch-the-fish.admin.permissions.moderate",{},!0),permission:"catchthefish.moderate"}),"moderate",90)];n().initializers.add("xxb-catch-the-fish",()=>{})})(),module.exports=e})(); +//# sourceMappingURL=admin.js.map \ No newline at end of file diff --git a/js/dist/admin.js.map b/js/dist/admin.js.map new file mode 100644 index 0000000..720a039 --- /dev/null +++ b/js/dist/admin.js.map @@ -0,0 +1 @@ +{"version":3,"file":"admin.js","mappings":"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","sources":["webpack://@xxb/catch-the-fish/webpack/bootstrap","webpack://@xxb/catch-the-fish/webpack/runtime/compat get default export","webpack://@xxb/catch-the-fish/webpack/runtime/define property getters","webpack://@xxb/catch-the-fish/webpack/runtime/hasOwnProperty shorthand","webpack://@xxb/catch-the-fish/webpack/runtime/make namespace object","webpack://@xxb/catch-the-fish/external root \"flarum.reg.get('core', 'admin/app')\"","webpack://@xxb/catch-the-fish/external root \"flarum.reg.get('core', 'common/extenders')\"","webpack://@xxb/catch-the-fish/./node_modules/@babel/runtime/helpers/esm/typeof.js","webpack://@xxb/catch-the-fish/./node_modules/@babel/runtime/helpers/esm/defineProperty.js","webpack://@xxb/catch-the-fish/./node_modules/@babel/runtime/helpers/esm/toPropertyKey.js","webpack://@xxb/catch-the-fish/./node_modules/@babel/runtime/helpers/esm/toPrimitive.js","webpack://@xxb/catch-the-fish/external root \"flarum.reg.get('core', 'common/components/Button')\"","webpack://@xxb/catch-the-fish/external root \"flarum.reg.get('core', 'common/components/LoadingIndicator')\"","webpack://@xxb/catch-the-fish/external root \"flarum.reg.get('core', 'common/components/Select')\"","webpack://@xxb/catch-the-fish/external root \"flarum.reg.get('core', 'common/components/Switch')\"","webpack://@xxb/catch-the-fish/external root \"flarum.reg.get('core', 'admin/components/ExtensionPage')\"","webpack://@xxb/catch-the-fish/./src/admin/SettingsPage.ts","webpack://@xxb/catch-the-fish/./src/admin/extend.ts","webpack://@xxb/catch-the-fish/./src/admin/index.ts"],"sourcesContent":["// The require scope\nconst __webpack_require__ = {};\n\n","// getDefaultExport function for compatibility with non-harmony modules\n__webpack_require__.n = (module) => {\n\tconst getter = module && module.__esModule ?\n\t\t() => (module['default']) :\n\t\t() => (module);\n\t__webpack_require__.d(getter, { a: getter });\n\treturn getter;\n};","// define getter/value functions for harmony exports\n__webpack_require__.d = (exports, definition) => {\n\tif(Array.isArray(definition)) {\n\t\tvar i = 0;\n\t\twhile(i < definition.length) {\n\t\t\tvar key = definition[i++];\n\t\t\tvar binding = definition[i++];\n\t\t\tif(!__webpack_require__.o(exports, key)) {\n\t\t\t\tif(binding === 0) {\n\t\t\t\t\tObject.defineProperty(exports, key, { enumerable: true, value: definition[i++] });\n\t\t\t\t} else {\n\t\t\t\t\tObject.defineProperty(exports, key, { enumerable: true, get: binding });\n\t\t\t\t}\n\t\t\t} else if(binding === 0) { i++; }\n\t\t}\n\t} else {\n\t\tfor(var key in definition) {\n\t\t\tif(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {\n\t\t\t\tObject.defineProperty(exports, key, { enumerable: true, get: definition[key] });\n\t\t\t}\n\t\t}\n\t}\n};","__webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))","// define __esModule on exports\n__webpack_require__.r = (exports) => {\n\tif(Symbol.toStringTag) {\n\t\tObject.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });\n\t}\n\tObject.defineProperty(exports, '__esModule', { value: true });\n};","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'admin/app');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/extenders');","function _typeof(o) {\n \"@babel/helpers - typeof\";\n\n return _typeof = \"function\" == typeof Symbol && \"symbol\" == typeof Symbol.iterator ? function (o) {\n return typeof o;\n } : function (o) {\n return o && \"function\" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? \"symbol\" : typeof o;\n }, _typeof(o);\n}\nexport { _typeof as default };","import toPropertyKey from \"./toPropertyKey.js\";\nfunction _defineProperty(e, r, t) {\n return (r = toPropertyKey(r)) in e ? Object.defineProperty(e, r, {\n value: t,\n enumerable: !0,\n configurable: !0,\n writable: !0\n }) : e[r] = t, e;\n}\nexport { _defineProperty as default };","import _typeof from \"./typeof.js\";\nimport toPrimitive from \"./toPrimitive.js\";\nfunction toPropertyKey(t) {\n var i = toPrimitive(t, \"string\");\n return \"symbol\" == _typeof(i) ? i : i + \"\";\n}\nexport { toPropertyKey as default };","import _typeof from \"./typeof.js\";\nfunction toPrimitive(t, r) {\n if (\"object\" != _typeof(t) || !t) return t;\n var e = t[Symbol.toPrimitive];\n if (void 0 !== e) {\n var i = e.call(t, r || \"default\");\n if (\"object\" != _typeof(i)) return i;\n throw new TypeError(\"@@toPrimitive must return a primitive value.\");\n }\n return (\"string\" === r ? String : Number)(t);\n}\nexport { toPrimitive as default };","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/components/Button');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/components/LoadingIndicator');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/components/Select');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/components/Switch');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'admin/components/ExtensionPage');","import _defineProperty from \"@babel/runtime/helpers/esm/defineProperty\";\nimport app from 'flarum/admin/app';\nimport Button from 'flarum/common/components/Button';\nimport LoadingIndicator from 'flarum/common/components/LoadingIndicator';\nimport Select from 'flarum/common/components/Select';\nimport Switch from 'flarum/common/components/Switch';\nimport ExtensionPage from 'flarum/admin/components/ExtensionPage';\nconst settingsPrefix = 'catch-the-fish.';\nconst translationPrefix = 'xxb-catch-the-fish.admin.settings.';\n\n// @ts-ignore missing view type-hint\nexport default class SettingsPage extends ExtensionPage {\n constructor() {\n super(...arguments);\n _defineProperty(this, \"allTagsLoaded\", false);\n _defineProperty(this, \"loadingAllTags\", false);\n _defineProperty(this, \"tagsAvailable\", true);\n _defineProperty(this, \"selectedTag\", '0');\n }\n oninit(vnode) {\n super.oninit(vnode);\n this.loadAllTags();\n }\n loadAllTags() {\n this.loadingAllTags = true;\n app.store.find('tags', {\n include: 'parent,lastPostedDiscussion'\n }).then(() => {\n this.tagsAvailable = true;\n this.allTagsLoaded = true;\n }).catch(() => {\n this.tagsAvailable = false;\n }).finally(() => {\n this.loadingAllTags = false;\n m.redraw();\n });\n }\n content() {\n return m('.ExtensionPage-settings', m('.container', [m('h3', app.translator.trans(translationPrefix + 'user')), m('.Form-group', [m('label', app.translator.trans(translationPrefix + 'user-age')), m('input.FormControl', {\n type: 'number',\n min: 1,\n step: 1,\n bidi: this.setting(settingsPrefix + 'userAgeDays', '14')\n })]), m('.Form-group', [m('label', app.translator.trans(translationPrefix + 'user-probability')), m('input.FormControl.CTFFormControl--range', {\n type: 'number',\n min: 0,\n max: 100,\n step: 1,\n bidi: this.setting(settingsPrefix + 'userProbability', '33')\n }), m('input', {\n type: 'range',\n min: 0,\n max: 100,\n step: 1,\n bidi: this.setting(settingsPrefix + 'userProbability', '33')\n })]), m('h3', app.translator.trans(translationPrefix + 'discussion')), m('.Form-group', [m('label', app.translator.trans(translationPrefix + 'discussion-age')), m('input.FormControl', {\n type: 'number',\n min: 1,\n step: 1,\n bidi: this.setting(settingsPrefix + 'discussionAgeDays', '14')\n })]), m('.Form-group', [m('label', app.translator.trans(translationPrefix + 'discussion-tags')), m('p.helpText', app.translator.trans(translationPrefix + 'discussion-tags-help')), this.tagSettings()]), m('h3', app.translator.trans(translationPrefix + 'post')), m('.Form-group', [m('label', app.translator.trans(translationPrefix + 'post-age')), m('input.FormControl', {\n type: 'number',\n min: 1,\n step: 1,\n bidi: this.setting(settingsPrefix + 'postAgeDays', '14')\n })]), m('.Form-group', [m('label', app.translator.trans(translationPrefix + 'post-probability')), m('input.FormControl.CTFFormControl--range', {\n type: 'number',\n min: 0,\n max: 100,\n step: 1,\n bidi: this.setting(settingsPrefix + 'postProbability', '50')\n }), m('input', {\n type: 'range',\n min: 0,\n max: 100,\n step: 1,\n bidi: this.setting(settingsPrefix + 'postProbability', '50')\n })]), m('h3', app.translator.trans(translationPrefix + 'general')), m('.Form-group', [m('label', app.translator.trans(translationPrefix + 'time-to-place')), m('input.FormControl', {\n type: 'number',\n min: 1,\n step: 1,\n bidi: this.setting(settingsPrefix + 'autoPlacedAfterMinutes', '5')\n })]), m('.Form-group', [Switch.component({\n state: this.setting(settingsPrefix + 'alertRound', '1')() === '1',\n onchange: value => {\n this.setting(settingsPrefix + 'alertRound')(value ? '1' : '0');\n }\n }, app.translator.trans(translationPrefix + 'alert-round'))]), m('.Form-group', [Switch.component({\n state: this.setting(settingsPrefix + 'animateFlip', '1')() === '1',\n onchange: value => {\n this.setting(settingsPrefix + 'animateFlip')(value ? '1' : '0');\n }\n }, app.translator.trans(translationPrefix + 'animate-flip'))]), m('.Form-group', this.submitButton())]));\n }\n tagSettings() {\n if (!this.tagsAvailable) {\n return m('p', m('em', app.translator.trans(translationPrefix + 'discussion-tags-unavailable')));\n }\n if (this.loadingAllTags) {\n return LoadingIndicator.component();\n }\n let tags = [];\n try {\n tags = JSON.parse(this.setting(settingsPrefix + 'discussionTags')());\n } catch (e) {}\n if (!Array.isArray(tags)) {\n tags = [];\n }\n let options = {\n '0': app.translator.trans(translationPrefix + 'tags-control.choose')\n };\n app.store.all('tags').sort((a, b) => {\n const aPosition = typeof a.position === 'function' ? a.position() : null;\n const bPosition = typeof b.position === 'function' ? b.position() : null;\n if (aPosition !== null && bPosition !== null && aPosition !== bPosition) {\n return aPosition - bPosition;\n }\n return String(a.name()).localeCompare(String(b.name()));\n }).forEach(tag => {\n if (tags.some(t => t.tagId === tag.id())) {\n return;\n }\n options[tag.id()] = tag.name();\n });\n return m('table.CTFTagsTable', [m('thead', m('tr', [m('th', app.translator.trans(translationPrefix + 'tags-header.tag')), m('th', app.translator.trans(translationPrefix + 'tags-header.probability')), m('th')])), m('tbody', [tags.map((tag, index) => {\n const onchange = event => {\n tag.probability = parseInt(event.target.value);\n this.setting(settingsPrefix + 'discussionTags')(JSON.stringify(tags));\n };\n const tagModel = app.store.getById('tags', tag.tagId);\n const isLastLine = index === tags.length - 1;\n return m('tr', [m('td', tagModel ? tagModel.name() : m('em', tag.tagId)), m('td', isLastLine ? m('em', app.translator.trans(translationPrefix + 'tags-fallback.' + (tags.length > 1 ? 'last-line' : 'single-line'))) : [m('input.FormControl.CTFFormControl--range', {\n type: 'number',\n min: 0,\n max: 100,\n step: 1,\n value: tag.probability || '',\n onchange\n }), m('input', {\n type: 'range',\n min: 0,\n max: 100,\n step: 1,\n value: tag.probability || '',\n onchange\n })]), m('td', [' ', Button.component({\n className: 'Button Button--icon',\n icon: 'fas fa-chevron-down',\n onclick: () => {\n tags.splice(index + 1, 0, ...tags.splice(index, 1));\n this.setting(settingsPrefix + 'discussionTags')(JSON.stringify(tags));\n },\n disabled: isLastLine,\n title: app.translator.trans(translationPrefix + 'tags-control.down')\n }), ' ', Button.component({\n className: 'Button Button--icon',\n icon: 'fas fa-chevron-up',\n onclick: () => {\n tags.splice(index - 1, 0, ...tags.splice(index, 1));\n this.setting(settingsPrefix + 'discussionTags')(JSON.stringify(tags));\n },\n disabled: index === 0,\n title: app.translator.trans(translationPrefix + 'tags-control.up')\n }), ' ', Button.component({\n className: 'Button Button--icon',\n icon: 'fas fa-times',\n onclick: () => {\n tags.splice(index, 1);\n this.setting(settingsPrefix + 'discussionTags')(JSON.stringify(tags));\n },\n title: app.translator.trans(translationPrefix + 'tags-control.delete')\n })])]);\n }), m('tr', [m('td', {\n colspan: 2\n }, Select.component({\n options,\n value: this.selectedTag,\n onchange: id => {\n this.selectedTag = id;\n }\n })), m('td', Button.component({\n className: 'Button Button--block',\n onclick: () => {\n tags.push({\n tagId: this.selectedTag,\n probability: 50\n });\n this.setting(settingsPrefix + 'discussionTags')(JSON.stringify(tags));\n this.selectedTag = '0';\n },\n disabled: this.selectedTag === '0'\n }, app.translator.trans(translationPrefix + 'tags-control.add')))])])]);\n }\n}","import Extend from 'flarum/common/extenders';\nimport app from 'flarum/admin/app';\nimport SettingsPage from './SettingsPage';\nexport default [new Extend.Admin().page(SettingsPage).permission(() => ({\n icon: 'fas fa-fish',\n label: app.translator.trans('xxb-catch-the-fish.admin.permissions.visible', {}, true),\n permission: 'catchthefish.visible'\n}), 'view', 90).permission(() => ({\n icon: 'fas fa-fish',\n label: app.translator.trans('xxb-catch-the-fish.admin.permissions.list-rankings', {}, true),\n permission: 'catchthefish.list-rankings'\n}), 'view', 80).permission(() => ({\n icon: 'fas fa-fish',\n label: app.translator.trans('xxb-catch-the-fish.admin.permissions.participate', {}, true),\n permission: 'catchthefish.participate'\n}), 'reply', 90).permission(() => ({\n icon: 'fas fa-fish',\n label: app.translator.trans('xxb-catch-the-fish.admin.permissions.choose-place', {}, true),\n permission: 'catchthefish.choose-place'\n}), 'reply', 80).permission(() => ({\n icon: 'fas fa-fish',\n label: app.translator.trans('xxb-catch-the-fish.admin.permissions.choose-name', {}, true),\n permission: 'catchthefish.choose-name'\n}), 'reply', 70).permission(() => ({\n icon: 'fas fa-fish',\n label: app.translator.trans('xxb-catch-the-fish.admin.permissions.moderate', {}, true),\n permission: 'catchthefish.moderate'\n}), 'moderate', 90)];","import app from 'flarum/admin/app';\nexport { default as extend } from './extend';\napp.initializers.add('xxb-catch-the-fish', () => {});"],"names":["__webpack_require__","module","getter","__esModule","d","a","exports","definition","Array","isArray","i","length","key","binding","o","Object","defineProperty","enumerable","value","get","obj","prop","prototype","hasOwnProperty","call","Symbol","toStringTag","app_namespaceObject","flarum","reg","extenders_namespaceObject","_typeof","iterator","constructor","_defineProperty","e","r","t","toPrimitive","TypeError","String","toPropertyKey","configurable","writable","Button_namespaceObject","LoadingIndicator_namespaceObject","Select_namespaceObject","Switch_namespaceObject","ExtensionPage_namespaceObject","settingsPrefix","translationPrefix","SettingsPage","ExtensionPage_default","super","arguments","this","oninit","vnode","loadAllTags","loadingAllTags","app_default","store","find","include","then","tagsAvailable","allTagsLoaded","catch","finally","m","redraw","content","translator","trans","type","min","step","bidi","setting","max","tagSettings","Switch_default","component","state","onchange","submitButton","LoadingIndicator_default","tags","JSON","parse","options","all","sort","b","aPosition","position","bPosition","name","localeCompare","forEach","tag","some","tagId","id","map","index","event","probability","parseInt","target","stringify","tagModel","getById","isLastLine","Button_default","className","icon","onclick","splice","disabled","title","colspan","Select_default","selectedTag","push","extend","extenders_default","Admin","page","permission","label","initializers","add"],"sourceRoot":""} \ No newline at end of file diff --git a/js/dist/forum.js b/js/dist/forum.js new file mode 100644 index 0000000..88a752c --- /dev/null +++ b/js/dist/forum.js @@ -0,0 +1,2 @@ +(()=>{var t={715(t){t.exports=function(){"use strict";return function(t,e,a){var s="h:mm A",r={lastDay:"[Yesterday at] "+s,sameDay:"[Today at] "+s,nextDay:"[Tomorrow at] "+s,nextWeek:"dddd [at] "+s,lastWeek:"[Last] dddd [at] "+s,sameElse:"MM/DD/YYYY"};e.prototype.calendar=function(t,e){var s=e||this.$locale().calendar||r,n=a(t||void 0).startOf("d"),o=this.diff(n,"d",!0),i="sameElse",h=o<-6?i:o<-1?"lastWeek":o<0?"lastDay":o<1?"sameDay":o<2?"nextDay":o<7?"nextWeek":i,c=s[h]||r[h];return"function"==typeof c?c.call(this,a()):this.format(c)}}}()}};const e={};function a(s){flarum.reg._webpack_runtimes["xxb-catch-the-fish"]||=a;const r=e[s];if(void 0!==r)return r.exports;const n=e[s]={exports:{}};return t[s].call(n.exports,n,n.exports,a),n.exports}a.n=t=>{const e=t&&t.__esModule?()=>t.default:()=>t;return a.d(e,{a:e}),e},a.d=(t,e)=>{if(Array.isArray(e))for(var s=0;sObject.prototype.hasOwnProperty.call(t,e),(()=>{"use strict";const t=flarum.reg.get("core","forum/app");var e=a.n(t);const s=flarum.reg.get("core","common/Model");var r=a.n(s);const n=flarum.reg.get("core","common/models/Discussion");var o=a.n(n);const i=flarum.reg.get("core","common/models/Post");var h=a.n(i);const c=flarum.reg.get("core","common/models/User");var u=a.n(c);const d=flarum.reg.get("core","common/models/Forum");var l=a.n(d);function f(t){return f="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},f(t)}function p(t,e,a){return(e=function(t){var e=function(t){if("object"!=f(t)||!t)return t;var e=t[Symbol.toPrimitive];if(void 0!==e){var a=e.call(t,"string");if("object"!=f(a))return a;throw new TypeError("@@toPrimitive must return a primitive value.")}return String(t)}(t);return"symbol"==f(e)?e:e+""}(e))in t?Object.defineProperty(t,e,{value:a,enumerable:!0,configurable:!0,writable:!0}):t[e]=a,t}class g extends(r()){constructor(){super(...arguments),p(this,"round_id",r().attribute("round_id")),p(this,"placement",r().attribute("placement")),p(this,"name",r().attribute("name")),p(this,"image_url",r().attribute("image_url")),p(this,"canSee",r().attribute("canSee")),p(this,"canCatch",r().attribute("canCatch")),p(this,"canName",r().attribute("canName")),p(this,"canPlace",r().attribute("canPlace")),p(this,"placeUntil",r().attribute("placeUntil")),p(this,"namedBy",r().hasOne("lastUserNaming")),p(this,"placedBy",r().hasOne("lastUserPlacement")),p(this,"round",r().hasOne("round")),p(this,"placementModel",r().hasOne("placement"))}apiEndpoint(){return"/catch-the-fish/"+(this.exists?"fishes/"+this.data.id:"rounds/"+this.data.attributes.round_id+"/fishes")}}flarum.reg.add("xxb-catch-the-fish","forum/models/Fish",g);class b extends(r()){constructor(){super(...arguments),p(this,"catch_count",r().attribute("catch_count")),p(this,"user",r().hasOne("user"))}}flarum.reg.add("xxb-catch-the-fish","forum/models/Ranking",b);class v extends(r()){constructor(){super(...arguments),p(this,"name",r().attribute("name")),p(this,"starts_at",r().attribute("starts_at")),p(this,"ends_at",r().attribute("ends_at")),p(this,"include_starting_pack",r().attribute("include_starting_pack")),p(this,"my_catch_count",r().attribute("my_catch_count")),p(this,"myRanking",r().hasOne("myRanking"))}apiEndpoint(){return"/catch-the-fish/rounds"+(this.exists?"/"+this.data.id:"")}}flarum.reg.add("xxb-catch-the-fish","forum/models/Round",v);const y=flarum.reg.get("core","common/extend"),x=flarum.reg.get("core","forum/components/HeaderPrimary");var F=a.n(x);const w=flarum.reg.get("core","common/utils/extractText");var k=a.n(w);const T="xxb-catch-the-fish.forum.fish-image.";class R{view(t){const a=t.attrs.fish.image_url();return a?m("img.catchthefish-image",{alt:k()(e().translator.trans(T+"alt",{name:t.attrs.fish.name()})),src:a,style:{animationDuration:t.attrs.animationDuration||"10s"},draggable:!1}):m(".catchthefish-no-image",e().translator.trans(T+"missing"))}}flarum.reg.add("xxb-catch-the-fish","forum/components/FishImage",R);const P="xxb-catch-the-fish.forum.basket.";class _{view(){if(!e().session||!e().session.user)return m("div");const t=e().session.user.catchTheFishBasket();if(!t)return m("div");const a=t.filter(t=>t.canPlace());return 0===a.length?m("div"):m(".catchthefish-basket",[m(".catchthefish-basket-title",e().translator.trans(P+"title")),m("p",e().translator.trans(P+"drag-help")),a.map(t=>m(".catchthefish-basket-entry",[m(".catchthefish-basket-fish",{className:e().catchTheFishSelectedFishId===t.id()?"active":"",draggable:!0,onclick(){e().catchTheFishSelectedFishId=e().catchTheFishSelectedFishId===t.id()?null:t.id()},ondragstart(a){a.dataTransfer&&(e().draggedFishId=t.id(),a.dataTransfer.setData("text/plain","fish:"+t.id()))}},m(R,{fish:t})),m(".catchthefish-basket-time",e().translator.trans(P+"time",{time:dayjs(t.placeUntil()).fromNow()}))]))])}}flarum.reg.add("xxb-catch-the-fish","forum/components/Basket",_);const B=flarum.reg.get("core","forum/components/CommentPost");var D=a.n(B);const A=flarum.reg.get("core","forum/components/UserCard");var N=a.n(A);const M=flarum.reg.get("core","forum/components/DiscussionHero");var O=a.n(M);class S{view(){return m(".catchthefish-drop-area",e().translator.trans("xxb-catch-the-fish.forum.drop-area.message"))}}flarum.reg.add("xxb-catch-the-fish","forum/components/DropArea",S);const I=flarum.reg.get("core","common/utils/classList");var C=a.n(I);const E=flarum.reg.get("core","common/components/Modal");var U=a.n(E);const j=flarum.reg.get("core","common/components/Button");var q=a.n(j);const L=flarum.reg.get("core","common/components/Link");var G=a.n(L);const Y=flarum.reg.get("core","common/components/Avatar");var W=a.n(Y);const z=flarum.reg.get("core","common/helpers/username");var H=a.n(z);class J{view(t){const a=t.attrs.user;return m(G(),{href:e().route.user(a)},[m(W(),{user:a})," ",H()(a)])}}flarum.reg.add("xxb-catch-the-fish","forum/components/User",J);const K="xxb-catch-the-fish.forum.caught-fish-modal.";class Q extends(U()){constructor(){super(...arguments),p(this,"newName",void 0),p(this,"dirty",!1),p(this,"loading",!1)}oninit(t){super.oninit(t),this.newName=this.attrs.fish.name()}className(){return"Modal--small catchthefish-catch-modal"}title(){return e().translator.trans(K+"title")}saveNameAndPlacement(t){void 0===t&&(t=!1);const a={};this.dirty&&(a.name=this.newName),t&&(a.placement="random"),a?(this.loading=!0,e().request({method:"POST",url:e().forum.attribute("apiUrl")+"/catch-the-fish/fishes/"+this.attrs.fish.id()+"/place",body:a}).then(a=>{e().store.pushPayload(a),this.hide(),this.attrs.fish.canPlace()&&!t&&e().store.find("users",e().session.user.id()).then(()=>{m.redraw()})}).catch(t=>{throw this.loading=!1,m.redraw(),t})):(this.hide(),this.attrs.fish.canPlace()&&!t&&e().store.find("users",e().session.user.id()).then(()=>{m.redraw()}))}content(){var t;const a=this.attrs.fish,s=a.namedBy(),r=a.placedBy();return m(".Modal-body",[m("h3",'"'+a.name()+'"'),m(R,{fish:a}),s?m("p",[e().translator.trans(K+"named-by")," ",m(J,{user:s})]):null,r?m("p",[e().translator.trans(K+"placed-by")," ",m(J,{user:r})]):null,m("p",e().translator.trans(K+"congratulation",{catch_count:(()=>{const t=a.round(),e=t&&t.myRanking?t.myRanking():null;return t&&"function"==typeof t.my_catch_count?t.my_catch_count():e?("function"==typeof e.catch_count?e.catch_count():e.data&&e.data.attributes&&e.data.attributes.catch_count||e.catch_count||0):0})()})),a.canName()?m(".Form-group",[m("p",e().translator.trans(K+"name-help")),m("label",e().translator.trans(K+"name")),m("input.FormControl",{value:this.newName,oninput:t=>{this.newName=t.target.value,this.dirty=!0}})]):null,a.canPlace()?m("p",e().translator.trans(K+"placement-help")):null,m(".Form-group",q().component({className:"Button Button--primary Button--block",type:"button",loading:this.loading,onclick:()=>{this.saveNameAndPlacement()}},e().translator.trans(K+(this.dirty?a.canPlace()?"submit-name-place-later":"submit-name":a.canPlace()?"submit-place-later":"submit-continue")))),a.canPlace()?m(".Form-group",q().component({className:"Button Button--primary Button--block",type:"button",loading:this.loading,onclick:()=>{this.saveNameAndPlacement(!0)}},e().translator.trans(K+(this.dirty?"submit-name-place-random":"submit-place-random")))):null])}onsubmit(t){t.preventDefault(),this.saveNameAndPlacement()}}flarum.reg.add("xxb-catch-the-fish","forum/modals/CaughtFishModal",Q);class V{constructor(){p(this,"reverseAnimation",void 0),p(this,"animationDuration",void 0)}oninit(){this.reverseAnimation=Math.random()>.5,this.animationDuration=5+Math.floor(70*Math.random())/10+"s"}view(t){const a=t.attrs.fish;return m(".catchthefish-moving-fish",{className:C()({"catchthefish-animate-flip":e().forum.catchTheFishAnimateFlip(),"catchthefish-animate-reverse":this.reverseAnimation}),style:{animationDuration:this.animationDuration},onclick:()=>{a.canCatch()||e().session.user?e().request({method:"POST",url:e().forum.attribute("apiUrl")+"/catch-the-fish/fishes/"+a.id()+"/catch",body:a.placement()}).then(a=>{e().modal.show(Q,{fish:e().store.pushPayload(a)}),t.attrs.oncatch&&t.attrs.oncatch()}):alert(e().translator.trans("xxb-catch-the-fish.forum.moving-fish.login"))}},[m(".catchthefish-name",a.name()),m(R,{fish:a,animationDuration:this.animationDuration})])}}function X(t){if(!t.dataTransfer)return null;const e=t.dataTransfer.getData("text/plain"),a=/^fish:([0-9]+)$/.exec(e);return a?a[1]:null}function $(){return e().draggedFishId||e().catchTheFishSelectedFishId||null}function te(t,a){const s=$();if(!s)return;const r=e().store.getById("catchthefish-fishes",s);if(r){const s={};s[a+"_id"]=t.id(),e().request({method:"POST",url:e().forum.attribute("apiUrl")+"/catch-the-fish/fishes/"+r.id()+"/place",body:{placement:s}}).then(t=>{e().store.pushPayload(t),e().draggedFishId=null,e().catchTheFishSelectedFishId=null,e().store.find("users",e().session.user.id()).then(()=>{m.redraw()})})}else alert(e().translator.trans("xxb-catch-the-fish.forum.drop-area.missing-from-store"))}function Z(t,a){const s=[];(t||e().catchTheFishSelectedFishId)&&s.push(m(S));const r=a.catchTheFishFishes();return r&&r.forEach(t=>{t.canSee()&&s.push(m(V,{fish:t,oncatch:()=>{a.pushData({relationships:{catchTheFishFishes:{data:a.data.relationships.catchTheFishFishes.data.filter(e=>e.id!==t.id())}}})}}))}),s}function tt(t,a){t.ondrop=t=>{this.fishDragOver=!1;const s=e().draggedFishId||X(t);if(s){t.preventDefault();const r=e().store.getById("catchthefish-fishes",s);if(r){const t={};t[a+"_id"]=this.attrs[a].id(),e().request({method:"POST",url:e().forum.attribute("apiUrl")+"/catch-the-fish/fishes/"+r.id()+"/place",body:{placement:t}}).then(t=>{e().store.pushPayload(t),e().draggedFishId=null,e().catchTheFishSelectedFishId=null,e().store.find("users",e().session.user.id()).then(()=>{m.redraw()})})}else alert(e().translator.trans("xxb-catch-the-fish.forum.drop-area.missing-from-store"))}},t.onclick=t=>{e().catchTheFishSelectedFishId&&(t.preventDefault(),t.stopPropagation(),te(this.attrs[a],a))},t.ondragover=t=>{e().draggedFishId||X(t)?(t.preventDefault(),t.dataTransfer.dropEffect="move",this.fishDragOver?t.redraw=!1:(this.fishDragOver=!0,m.redraw())):(t.dataTransfer&&t.dataTransfer.types.includes("text/plain")&&t.preventDefault(),t.redraw=!1)},t.ondragenter=t=>{t.preventDefault(),t.redraw=!1},t.ondragleave=t=>{this.fishDragOver?(this.fishDragOver=!1,m.redraw()):t.redraw=!1}}function et(t,e,a){"content"===e?(0,y.override)(t,e,function(t){return t().concat(Z(this.fishDragOver,this.attrs[a]))}):(0,y.extend)(t,e,function(t){t.add("catchthefish-fish-and-drop",Z(this.fishDragOver,this.attrs[a]))}),(0,y.extend)(t,"oninit",function(){this.fishDragOver=!1,this.subtree&&this.subtree.check(()=>this.fishDragOver)}),"content"===e?(0,y.extend)(t,"elementAttrs",function(t){tt.bind(this)(t,a)}):(0,y.extend)(t,"view",function(t){t.attrs=t.attrs||{},tt.bind(this)(t.attrs,a)})}flarum.reg.add("xxb-catch-the-fish","forum/components/MovingFish",V);const at=flarum.reg.get("core","common/components/Page");var st=a.n(at);const rt="xxb-catch-the-fish.forum.table-ranking.";class nt{constructor(){p(this,"rankings",null)}oninit(t){e().request({method:"GET",url:e().forum.attribute("apiUrl")+"/catch-the-fish/rounds/"+t.attrs.round.id()+"/rankings"}).then(t=>{this.rankings=e().store.pushPayload(t),m.redraw()})}view(t){return null===this.rankings?m("p",e().translator.trans(rt+"loading")):m("div",[m("h2",e().translator.trans(rt+"title",{name:t.attrs.round.name()})),m("table.catchthefish-table",[m("thead",m("tr",[m("th",e().translator.trans(rt+"rank")),m("th",e().translator.trans(rt+"count")),m("th",e().translator.trans(rt+"user"))])),m("tbody",this.rankings.map((t,e)=>m("tr",[m("td","#"+(e+1)),m("td",t.catch_count()),m("td",m(J,{user:t.user()}))])))])])}}flarum.reg.add("xxb-catch-the-fish","forum/components/RoundRankings",nt);const ot="xxb-catch-the-fish.forum.page-ranking.";class it extends(st()){view(){if(!e().forum.catchTheFishCanSeeRankingsPage())return m(".container",m("p",e().translator.trans(ot+"permission")));const t=e().forum.catchTheFishActiveRounds();return m(".container",[m("h2",e().translator.trans(ot+"title")),t?t.map(t=>m(nt,{round:t})):m("p",e().translator.trans(ot+"nothing"))])}}flarum.reg.add("xxb-catch-the-fish","forum/pages/Rankings",it);const ht=flarum.reg.get("core","common/components/LinkButton");var ct=a.n(ht);const mt=flarum.reg.get("core","common/components/Switch");var ut=a.n(mt);const dt=flarum.reg.get("core","common/utils/withAttr");var lt=a.n(dt);const ft="xxb-catch-the-fish.forum.edit-round.";class pt{constructor(){p(this,"round",void 0),p(this,"dirty",!1),p(this,"processing",!1)}oninit(t){let a=t.attrs.round;void 0===a&&(a=e().store.createRecord("catchthefish-rounds",{attributes:{name:"",starts_at:"",ends_at:dayjs().add(1,"day").toISOString(),include_starting_pack:!0}})),this.round=a}view(t){return m("form",{onsubmit:e=>{e.preventDefault(),this.saveRecord(t)}},[m(".Form-group",[m("label",e().translator.trans(ft+"name")),m("input.FormControl",{value:this.round.name(),oninput:lt()("value",this.updateAttribute.bind(this,"name"))}),m(".helpText",e().translator.trans(ft+"name-help"))]),m(".Form-group",[m("label",e().translator.trans(ft+"starts-at")),m("input.FormControl",{value:this.round.starts_at(),oninput:lt()("value",this.updateAttribute.bind(this,"starts_at"))})]),m(".Form-group",[m("label",e().translator.trans(ft+"ends-at")),m("input.FormControl",{value:this.round.ends_at(),oninput:lt()("value",this.updateAttribute.bind(this,"ends_at"))})]),this.round.exists?"":m(".Form-group",[m("label",[ut().component({state:this.round.include_starting_pack(),onchange:this.updateAttribute.bind(this,"include_starting_pack")},e().translator.trans(ft+"starting-pack"))]),m(".helpText",e().translator.trans(ft+"starting-pack-help"))]),m(".ButtonGroup",[q().component({type:"submit",className:"Button Button--primary",loading:this.processing,disabled:!this.readyToSave()},e().translator.trans(ft+(this.round.exists?"save":"create"))),this.round.exists?q().component({type:"button",className:"Button Button--danger",loading:this.processing,onclick:()=>{this.deleteRecord(t)}},e().translator.trans(ft+"delete")):""])])}updateAttribute(t,e){this.round.pushAttributes({[t]:e}),this.dirty=!0}readyToSave(){return!!this.round.name()&&this.dirty}saveRecord(t){this.processing=!0,this.round.save(this.round.data.attributes).then(()=>{t.attrs.onsave&&t.attrs.onsave(),this.processing=!1,this.dirty=!1,m.redraw()}).catch(t=>{throw this.processing=!1,m.redraw(),t})}deleteRecord(t){confirm(k()(e().translator.trans(ft+"delete-confirmation",{name:this.round.name()})))&&(this.processing=!0,this.round.delete().then(()=>{t.attrs.ondelete&&t.attrs.ondelete(),this.processing=!1,m.redraw()}).catch(t=>{throw this.processing=!1,m.redraw(),t}))}}flarum.reg.add("xxb-catch-the-fish","forum/components/EditRound",pt);class gt extends(U()){className(){return"Modal--small"}title(){return e().translator.trans("xxb-catch-the-fish.forum.new-round-modal.title")}content(){return m(".Modal-body",m(pt,{onsave:()=>{this.hide(),this.attrs.oncreateordelete&&this.attrs.oncreateordelete()}}))}}flarum.reg.add("xxb-catch-the-fish","forum/modals/NewRoundModal",gt);class bt extends(U()){className(){return"Modal--small"}title(){return e().translator.trans("xxb-catch-the-fish.forum.edit-round-modal.title",{name:this.attrs.round.name()})}content(){return m(".Modal-body",m(pt,{round:this.attrs.round,ondelete:()=>{this.hide(),this.attrs.oncreateordelete&&this.attrs.oncreateordelete()}}))}}flarum.reg.add("xxb-catch-the-fish","forum/modals/EditRoundModal",bt);const vt="xxb-catch-the-fish.forum.table-round.";class yt extends(st()){constructor(){super(...arguments),p(this,"rounds",null)}oninit(t){super.oninit(t),this.refreshRounds()}refreshRounds(){this.rounds=null,e().request({method:"GET",url:e().forum.attribute("apiUrl")+"/catch-the-fish/rounds"}).then(t=>{this.rounds=e().store.pushPayload(t),m.redraw()})}view(){return null===this.rounds?m(".container",m("p",e().translator.trans(vt+"loading"))):m(".container",[m("h2",e().translator.trans(vt+"title")),q().component({className:"Button Button--primary",onclick:()=>{e().modal.show(gt,{oncreateordelete:()=>{this.refreshRounds()}})}},e().translator.trans(vt+"new")),m("table.catchthefish-table",[m("thead",m("tr",[m("th",e().translator.trans(vt+"name")),m("th",e().translator.trans(vt+"start")),m("th",e().translator.trans(vt+"end")),m("th",e().translator.trans(vt+"actions"))])),m("tbody",this.rounds.map(t=>m("tr",[m("td",t.name()),m("td",t.starts_at()),m("td",t.ends_at()),m("td",[q().component({className:"Button",onclick:()=>{e().modal.show(bt,{round:t,oncreateordelete:()=>{this.refreshRounds()}})}},e().translator.trans(vt+"edit"))," ",ct().component({className:"Button",href:e().route("catchTheFishRound",{id:t.id()})},e().translator.trans(vt+"fishes"))])])))])])}}flarum.reg.add("xxb-catch-the-fish","forum/pages/RoundList",yt);const xt="xxb-catch-the-fish.forum.edit-fish.";class Ft{constructor(){p(this,"fish",void 0),p(this,"dirty",!1),p(this,"processing",!1)}oninit(t){let a=t.attrs.fish;void 0===a&&(a=e().store.createRecord("catchthefish-fishes",{attributes:{round_id:t.attrs.round.id(),name:""}})),this.fish=a}view(t){return m("form",{onsubmit:e=>{e.preventDefault(),this.saveRecord(t)}},[m(".Form-group",[m("label",e().translator.trans(xt+"name")),m("input.FormControl",{value:this.fish.name(),oninput:t=>{this.fish.pushAttributes({name:t.target.value}),this.dirty=!0}}),m(".helpText",e().translator.trans(xt+"name-help"))]),m(".ButtonGroup",[q().component({type:"submit",className:"Button Button--primary",loading:this.processing,disabled:!this.readyToSave()},e().translator.trans(xt+(this.fish.exists?"save":"create"))),this.fish.exists?q().component({type:"button",className:"Button Button--danger",loading:this.processing,onclick:()=>{this.deleteRecord(t)}},e().translator.trans(xt+"delete")):""])])}readyToSave(){return!!this.fish.name()&&this.dirty}saveRecord(t){this.processing=!0,this.fish.save(this.fish.data.attributes).then(()=>{t.attrs.onsave&&t.attrs.onsave(),this.processing=!1,this.dirty=!1,m.redraw()}).catch(t=>{throw this.processing=!1,m.redraw(),t})}deleteRecord(t){confirm(k()(e().translator.trans(xt+"delete-confirmation",{name:this.fish.name()})))&&(this.processing=!0,this.fish.delete().then(()=>{t.attrs.ondelete&&t.attrs.ondelete(),this.processing=!1,m.redraw()}).catch(t=>{throw this.processing=!1,m.redraw(),t}))}}flarum.reg.add("xxb-catch-the-fish","forum/components/EditFish",Ft);class wt extends(U()){className(){return"Modal--small"}title(){return e().translator.trans("xxb-catch-the-fish.forum.new-fish-modal.title")}content(){return m(".Modal-body",m(Ft,{round:this.attrs.round,onsave:()=>{this.hide(),this.attrs.oncreateordelete&&this.attrs.oncreateordelete()}}))}}flarum.reg.add("xxb-catch-the-fish","forum/modals/NewFishModal",wt);class kt extends(U()){className(){return"Modal--small"}title(){return e().translator.trans("xxb-catch-the-fish.forum.edit-fish-modal.title",{name:this.attrs.fish.name()})}content(){return m(".Modal-body",m(Ft,{fish:this.attrs.fish,ondelete:()=>{this.hide(),this.attrs.oncreateordelete&&this.attrs.oncreateordelete()}}))}}flarum.reg.add("xxb-catch-the-fish","forum/modals/EditFishModal",kt);const Tt="xxb-catch-the-fish.forum.table-fish.";class Rt extends(st()){constructor(){super(...arguments),p(this,"roundId",void 0),p(this,"round",null),p(this,"fishes",null),p(this,"uploading",!1)}oninit(t){super.oninit(t),this.roundId=m.route.param("id"),this.round=e().store.getById("catchthefish-round",this.roundId),this.round||e().store.find("catch-the-fish/rounds",this.roundId).then(t=>{this.round=t,m.redraw()}),this.refreshFishes()}refreshFishes(){this.fishes=null,e().request({method:"GET",url:e().forum.attribute("apiUrl")+"/catch-the-fish/rounds/"+this.roundId+"/fishes"}).then(t=>{this.fishes=e().store.pushPayload(t),m.redraw()})}openPicker(t,e){if(void 0===e&&(e=!1),this.uploading)return;const a=$('');a.attr("multiple",e),a.appendTo("body").hide().click().on("change",e=>{t(e.target.files)})}uploadImages(t,a){const s=new FormData;if(a)s.append("image",t[0]);else for(let e=0;et,body:s}).then(()=>{this.uploading=!1,m.redraw(),this.refreshFishes()}).catch(t=>{throw this.uploading=!1,m.redraw(),t})}view(){return this.round&&null!==this.fishes?m(".container",[m("h2",e().translator.trans(Tt+"title",{name:this.round.name()})),q().component({className:"Button Button--primary",onclick:()=>{e().modal.show(wt,{round:this.round,oncreateordelete:()=>{this.refreshFishes()}})}},e().translator.trans(Tt+"new"))," ",q().component({className:"Button",type:"button",onclick:()=>{this.openPicker(t=>{this.uploadImages(t)},!0)},loading:this.uploading},e().translator.trans(Tt+"new-from-image")),m("table.catchthefish-table",[m("thead",m("tr",[m("th",e().translator.trans(Tt+"image")),m("th",e().translator.trans(Tt+"name")),m("th",e().translator.trans(Tt+"user-name")),m("th",e().translator.trans(Tt+"user-place")),m("th",e().translator.trans(Tt+"placement")),m("th",e().translator.trans(Tt+"actions"))])),m("tbody",0===this.fishes.length?m("tr",[m("td","No fishes")]):this.fishes.map(t=>{const a=t.namedBy(),s=t.placedBy();return m("tr",[m("td",m(R,{fish:t})),m("td",t.name()),m("td",a?m(J,{user:a}):m("em",e().translator.trans(Tt+"no-user-name"))),m("td",s?m(J,{user:s}):m("em",e().translator.trans(Tt+"no-user-place"))),m("td",this.placement(t)),m("td",[q().component({className:"Button",onclick:()=>{e().modal.show(kt,{fish:t,oncreateordelete:()=>{this.refreshFishes()}})}},e().translator.trans(Tt+"edit"))," ",q().component({className:"Button",type:"button",onclick:()=>{this.openPicker(e=>{this.uploadImages(e,t)})},loading:this.uploading},e().translator.trans(Tt+"upload"))])])}))])]):m(".container",m("p",e().translator.trans(Tt+"loading")))}placement(t){const a=t.placementModel();if(a instanceof o())return m(G(),{href:e().route.discussion(a)},a.title());if(a instanceof h()){const t=a.discussion();return m(G(),{href:e().route.post(a)},(t?t.title():"")+" #"+a.number())}return a instanceof u()?m(J,{user:a}):"N/A"}}flarum.reg.add("xxb-catch-the-fish","forum/pages/RoundSettings",Rt);const Pt=flarum.reg.get("core","forum/components/IndexPage");var _t=a.n(Pt);const $t=flarum.reg.get("core","forum/components/IndexSidebar");var zt=a.n($t);const Bt="xxb-catch-the-fish.forum.nav.",Dt="xxb-catch-the-fish.forum.round-alert.";class At{view(t){return m(".Alert.Alert-info",m(".container",[m("span.Alert-body",[e().translator.trans(Dt+"message",{name:t.attrs.round.name(),time:dayjs(t.attrs.round.ends_at()).calendar()})]),e().forum.catchTheFishCanSeeRankingsPage()?m("ul.Alert-controls",m("li",ct().component({href:e().route("catchTheFishRankings")},e().translator.trans(Dt+"rankings")))):null]))}}flarum.reg.add("xxb-catch-the-fish","forum/components/RoundAlert",At);var Nt=a(715),Mt=a.n(Nt);dayjs.extend(Mt()),e().initializers.add("xxb-catch-the-fish",()=>{e().store.models["catchthefish-fishes"]=g,e().store.models["catchthefish-rankings"]=b,e().store.models["catchthefish-rounds"]=v,o().prototype.catchTheFishFishes=r().hasMany("catchTheFishFishes"),h().prototype.catchTheFishFishes=r().hasMany("catchTheFishFishes"),u().prototype.catchTheFishFishes=r().hasMany("catchTheFishFishes"),u().prototype.catchTheFishBasket=r().hasMany("catchTheFishBasket"),l().prototype.catchTheFishActiveRounds=r().hasMany("catchTheFishActiveRounds"),l().prototype.catchTheFishCanModerate=r().attribute("catchTheFishCanModerate"),l().prototype.catchTheFishCanSeeRankingsPage=r().attribute("catchTheFishCanSeeRankingsPage"),l().prototype.catchTheFishAnimateFlip=r().attribute("catchTheFishAnimateFlip"),(()=>{let t=document.querySelector(".catchthefish-basket-root");t||(t=document.createElement("div"),t.className="catchthefish-basket-root",document.body.appendChild(t)),m.mount(t,_)})(),et(D().prototype,"content","post"),et(N().prototype,"infoItems","user"),et(O().prototype,"items","discussion"),(0,y.extend)(N().prototype,"oninit",function(){const t=this.attrs.user;t&&t.exists&&!(t.data.relationships&&t.data.relationships.catchTheFishFishes)&&!t.catchTheFishReloading&&(t.catchTheFishReloading=!0,e().store.find("users",t.id()).then(()=>{m.redraw()}).finally(()=>{t.catchTheFishReloading=!1}))}),document.addEventListener("dragend",()=>{e().draggedFishId=null}),e().routes.catchTheFishRankings={path:"/catch-the-fish",component:it},e().routes.catchTheFishRounds={path:"/catch-the-fish/rounds",component:yt},e().routes.catchTheFishRound={path:"/catch-the-fish/rounds/:id",component:Rt},(0,y.extend)(zt().prototype,"navItems",function(t){e().forum.catchTheFishCanSeeRankingsPage()&&t.add("catchthefish-rankings",ct().component({icon:"fas fa-fish",href:e().route("catchTheFishRankings")},e().translator.trans(Bt+"rankings"))),e().forum.catchTheFishCanModerate()&&t.add("catchthefish-settings",ct().component({icon:"fas fa-fish",href:e().route("catchTheFishRounds")},e().translator.trans(Bt+"settings")))}),(0,y.override)(_t().prototype,"hero",function(t){const a=t();if(!e().forum.attribute("catchTheFishAlertRound"))return a;const s=e().forum.catchTheFishActiveRounds();if(!s)return a;const r=s.map(t=>t?m(At,{round:t}):null);return Array.isArray(a)?s.concat(a):m("div",[r,a])})})})(),module.exports={}})(); +//# sourceMappingURL=forum.js.map \ No newline at end of file diff --git a/js/dist/forum.js.map b/js/dist/forum.js.map new file mode 100644 index 0000000..3fb998e --- /dev/null +++ b/js/dist/forum.js.map @@ -0,0 +1 @@ +{"version":3,"file":"forum.js","mappings":"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","sources":["webpack://@xxb/catch-the-fish/./node_modules/dayjs/plugin/calendar.js","webpack://@xxb/catch-the-fish/webpack/bootstrap","webpack://@xxb/catch-the-fish/webpack/runtime/compat get default export","webpack://@xxb/catch-the-fish/webpack/runtime/define property getters","webpack://@xxb/catch-the-fish/webpack/runtime/hasOwnProperty shorthand","webpack://@xxb/catch-the-fish/external root \"flarum.reg.get('core', 'forum/app')\"","webpack://@xxb/catch-the-fish/external root \"flarum.reg.get('core', 'common/Model')\"","webpack://@xxb/catch-the-fish/external root \"flarum.reg.get('core', 'common/models/Discussion')\"","webpack://@xxb/catch-the-fish/external root \"flarum.reg.get('core', 'common/models/Post')\"","webpack://@xxb/catch-the-fish/external root \"flarum.reg.get('core', 'common/models/User')\"","webpack://@xxb/catch-the-fish/external root \"flarum.reg.get('core', 'common/models/Forum')\"","webpack://@xxb/catch-the-fish/./node_modules/@babel/runtime/helpers/esm/typeof.js","webpack://@xxb/catch-the-fish/./node_modules/@babel/runtime/helpers/esm/defineProperty.js","webpack://@xxb/catch-the-fish/./node_modules/@babel/runtime/helpers/esm/toPropertyKey.js","webpack://@xxb/catch-the-fish/./node_modules/@babel/runtime/helpers/esm/toPrimitive.js","webpack://@xxb/catch-the-fish/./src/forum/models/Fish.ts","webpack://@xxb/catch-the-fish/./src/forum/models/Ranking.ts","webpack://@xxb/catch-the-fish/./src/forum/models/Round.ts","webpack://@xxb/catch-the-fish/external root \"flarum.reg.get('core', 'common/extend')\"","webpack://@xxb/catch-the-fish/external root \"flarum.reg.get('core', 'forum/components/HeaderPrimary')\"","webpack://@xxb/catch-the-fish/external root \"flarum.reg.get('core', 'common/utils/extractText')\"","webpack://@xxb/catch-the-fish/./src/forum/components/FishImage.ts","webpack://@xxb/catch-the-fish/./src/forum/components/Basket.ts","webpack://@xxb/catch-the-fish/external root \"flarum.reg.get('core', 'forum/components/CommentPost')\"","webpack://@xxb/catch-the-fish/external root \"flarum.reg.get('core', 'forum/components/UserCard')\"","webpack://@xxb/catch-the-fish/external root \"flarum.reg.get('core', 'forum/components/DiscussionHero')\"","webpack://@xxb/catch-the-fish/./src/forum/components/DropArea.ts","webpack://@xxb/catch-the-fish/external root \"flarum.reg.get('core', 'common/utils/classList')\"","webpack://@xxb/catch-the-fish/external root \"flarum.reg.get('core', 'common/components/Modal')\"","webpack://@xxb/catch-the-fish/external root \"flarum.reg.get('core', 'common/components/Button')\"","webpack://@xxb/catch-the-fish/external root \"flarum.reg.get('core', 'common/components/Link')\"","webpack://@xxb/catch-the-fish/external root \"flarum.reg.get('core', 'common/components/Avatar')\"","webpack://@xxb/catch-the-fish/external root \"flarum.reg.get('core', 'common/helpers/username')\"","webpack://@xxb/catch-the-fish/./src/forum/components/User.ts","webpack://@xxb/catch-the-fish/./src/forum/modals/CaughtFishModal.ts","webpack://@xxb/catch-the-fish/./src/forum/components/MovingFish.ts","webpack://@xxb/catch-the-fish/./src/forum/addDropAreas.ts","webpack://@xxb/catch-the-fish/external root \"flarum.reg.get('core', 'common/components/Page')\"","webpack://@xxb/catch-the-fish/./src/forum/components/RoundRankings.ts","webpack://@xxb/catch-the-fish/./src/forum/pages/Rankings.ts","webpack://@xxb/catch-the-fish/external root \"flarum.reg.get('core', 'common/components/LinkButton')\"","webpack://@xxb/catch-the-fish/external root \"flarum.reg.get('core', 'common/components/Switch')\"","webpack://@xxb/catch-the-fish/external root \"flarum.reg.get('core', 'common/utils/withAttr')\"","webpack://@xxb/catch-the-fish/./src/forum/components/EditRound.ts","webpack://@xxb/catch-the-fish/./src/forum/modals/NewRoundModal.ts","webpack://@xxb/catch-the-fish/./src/forum/modals/EditRoundModal.ts","webpack://@xxb/catch-the-fish/./src/forum/pages/RoundList.ts","webpack://@xxb/catch-the-fish/./src/forum/components/EditFish.ts","webpack://@xxb/catch-the-fish/./src/forum/modals/NewFishModal.ts","webpack://@xxb/catch-the-fish/./src/forum/modals/EditFishModal.ts","webpack://@xxb/catch-the-fish/./src/forum/pages/RoundSettings.ts","webpack://@xxb/catch-the-fish/external root \"flarum.reg.get('core', 'forum/components/IndexPage')\"","webpack://@xxb/catch-the-fish/./src/forum/addNavLinks.ts","webpack://@xxb/catch-the-fish/./src/forum/components/RoundAlert.ts","webpack://@xxb/catch-the-fish/./src/forum/addRoundAlert.ts","webpack://@xxb/catch-the-fish/./src/forum/index.ts","webpack://@xxb/catch-the-fish/./src/forum/addModels.ts","webpack://@xxb/catch-the-fish/./src/forum/addFishBasket.ts","webpack://@xxb/catch-the-fish/./src/forum/addPages.ts"],"sourcesContent":["!function(e,t){\"object\"==typeof exports&&\"undefined\"!=typeof module?module.exports=t():\"function\"==typeof define&&define.amd?define(t):(e=\"undefined\"!=typeof globalThis?globalThis:e||self).dayjs_plugin_calendar=t()}(this,(function(){\"use strict\";return function(e,t,a){var n=\"h:mm A\",d={lastDay:\"[Yesterday at] \"+n,sameDay:\"[Today at] \"+n,nextDay:\"[Tomorrow at] \"+n,nextWeek:\"dddd [at] \"+n,lastWeek:\"[Last] dddd [at] \"+n,sameElse:\"MM/DD/YYYY\"};t.prototype.calendar=function(e,t){var n=t||this.$locale().calendar||d,o=a(e||void 0).startOf(\"d\"),s=this.diff(o,\"d\",!0),i=\"sameElse\",f=s<-6?i:s<-1?\"lastWeek\":s<0?\"lastDay\":s<1?\"sameDay\":s<2?\"nextDay\":s<7?\"nextWeek\":i,l=n[f]||d[f];return\"function\"==typeof l?l.call(this,a()):this.format(l)}}}));","// The module cache\nconst __webpack_module_cache__ = {};\n\n// The require function\nfunction __webpack_require__(moduleId) {\n\tflarum.reg._webpack_runtimes[\"xxb-catch-the-fish\"] ||= __webpack_require__;// Check if module is in cache\n\tconst cachedModule = __webpack_module_cache__[moduleId];\n\tif (cachedModule !== undefined) {\n\t\treturn cachedModule.exports;\n\t}\n\t// Create a new module (and put it into the cache)\n\tconst module = __webpack_module_cache__[moduleId] = {\n\t\t// no module.id needed\n\t\t// no module.loaded needed\n\t\texports: {}\n\t};\n\n\t// Execute the module function\n\t__webpack_modules__[moduleId].call(module.exports, module, module.exports, __webpack_require__);\n\n\t// Return the exports of the module\n\treturn module.exports;\n}\n\n","// getDefaultExport function for compatibility with non-harmony modules\n__webpack_require__.n = (module) => {\n\tconst getter = module && module.__esModule ?\n\t\t() => (module['default']) :\n\t\t() => (module);\n\t__webpack_require__.d(getter, { a: getter });\n\treturn getter;\n};","// define getter/value functions for harmony exports\n__webpack_require__.d = (exports, definition) => {\n\tif(Array.isArray(definition)) {\n\t\tvar i = 0;\n\t\twhile(i < definition.length) {\n\t\t\tvar key = definition[i++];\n\t\t\tvar binding = definition[i++];\n\t\t\tif(!__webpack_require__.o(exports, key)) {\n\t\t\t\tif(binding === 0) {\n\t\t\t\t\tObject.defineProperty(exports, key, { enumerable: true, value: definition[i++] });\n\t\t\t\t} else {\n\t\t\t\t\tObject.defineProperty(exports, key, { enumerable: true, get: binding });\n\t\t\t\t}\n\t\t\t} else if(binding === 0) { i++; }\n\t\t}\n\t} else {\n\t\tfor(var key in definition) {\n\t\t\tif(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {\n\t\t\t\tObject.defineProperty(exports, key, { enumerable: true, get: definition[key] });\n\t\t\t}\n\t\t}\n\t}\n};","__webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'forum/app');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/Model');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/models/Discussion');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/models/Post');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/models/User');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/models/Forum');","function _typeof(o) {\n \"@babel/helpers - typeof\";\n\n return _typeof = \"function\" == typeof Symbol && \"symbol\" == typeof Symbol.iterator ? function (o) {\n return typeof o;\n } : function (o) {\n return o && \"function\" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? \"symbol\" : typeof o;\n }, _typeof(o);\n}\nexport { _typeof as default };","import toPropertyKey from \"./toPropertyKey.js\";\nfunction _defineProperty(e, r, t) {\n return (r = toPropertyKey(r)) in e ? Object.defineProperty(e, r, {\n value: t,\n enumerable: !0,\n configurable: !0,\n writable: !0\n }) : e[r] = t, e;\n}\nexport { _defineProperty as default };","import _typeof from \"./typeof.js\";\nimport toPrimitive from \"./toPrimitive.js\";\nfunction toPropertyKey(t) {\n var i = toPrimitive(t, \"string\");\n return \"symbol\" == _typeof(i) ? i : i + \"\";\n}\nexport { toPropertyKey as default };","import _typeof from \"./typeof.js\";\nfunction toPrimitive(t, r) {\n if (\"object\" != _typeof(t) || !t) return t;\n var e = t[Symbol.toPrimitive];\n if (void 0 !== e) {\n var i = e.call(t, r || \"default\");\n if (\"object\" != _typeof(i)) return i;\n throw new TypeError(\"@@toPrimitive must return a primitive value.\");\n }\n return (\"string\" === r ? String : Number)(t);\n}\nexport { toPrimitive as default };","import _defineProperty from \"@babel/runtime/helpers/esm/defineProperty\";\nimport Model from 'flarum/common/Model';\nexport default class Fish extends Model {\n constructor() {\n super(...arguments);\n _defineProperty(this, \"round_id\", Model.attribute('round_id'));\n // Just for the creation apiEndpoint creation\n _defineProperty(this, \"placement\", Model.attribute('placement'));\n // Object\n _defineProperty(this, \"name\", Model.attribute('name'));\n _defineProperty(this, \"image_url\", Model.attribute('image_url'));\n _defineProperty(this, \"canSee\", Model.attribute('canSee'));\n _defineProperty(this, \"canCatch\", Model.attribute('canCatch'));\n _defineProperty(this, \"canName\", Model.attribute('canName'));\n _defineProperty(this, \"canPlace\", Model.attribute('canPlace'));\n _defineProperty(this, \"placeUntil\", Model.attribute('placeUntil'));\n _defineProperty(this, \"namedBy\", Model.hasOne('lastUserNaming'));\n _defineProperty(this, \"placedBy\", Model.hasOne('lastUserPlacement'));\n _defineProperty(this, \"round\", Model.hasOne('round'));\n _defineProperty(this, \"placementModel\", Model.hasOne('placement'));\n }\n // Only used by the admin panel to show the link to the resource\n\n apiEndpoint() {\n return '/catch-the-fish/' + (this.exists ? 'fishes/' + this.data.id : 'rounds/' + this.data.attributes.round_id + '/fishes');\n }\n}\nflarum.reg.add('xxb-catch-the-fish', 'forum/models/Fish', Fish);","import _defineProperty from \"@babel/runtime/helpers/esm/defineProperty\";\nimport Model from 'flarum/common/Model';\nexport default class Ranking extends Model {\n constructor() {\n super(...arguments);\n _defineProperty(this, \"catch_count\", Model.attribute('catch_count'));\n _defineProperty(this, \"user\", Model.hasOne('user'));\n }\n}\nflarum.reg.add('xxb-catch-the-fish', 'forum/models/Ranking', Ranking);","import _defineProperty from \"@babel/runtime/helpers/esm/defineProperty\";\nimport Model from 'flarum/common/Model';\nexport default class Round extends Model {\n constructor() {\n super(...arguments);\n _defineProperty(this, \"name\", Model.attribute('name'));\n _defineProperty(this, \"starts_at\", Model.attribute('starts_at'));\n _defineProperty(this, \"ends_at\", Model.attribute('ends_at'));\n _defineProperty(this, \"include_starting_pack\", Model.attribute('include_starting_pack'));\n _defineProperty(this, \"myRanking\", Model.hasOne('myRanking'));\n }\n apiEndpoint() {\n return '/catch-the-fish/rounds' + (this.exists ? '/' + this.data.id : '');\n }\n}\nflarum.reg.add('xxb-catch-the-fish', 'forum/models/Round', Round);","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/extend');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'forum/components/HeaderPrimary');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/utils/extractText');","import app from 'flarum/forum/app';\nimport extractText from 'flarum/common/utils/extractText';\nconst translationPrefix = 'xxb-catch-the-fish.forum.fish-image.';\nexport default class FishImage {\n view(vnode) {\n const src = vnode.attrs.fish.image_url();\n if (src) {\n return m('img.catchthefish-image', {\n alt: extractText(app.translator.trans(translationPrefix + 'alt', {\n name: vnode.attrs.fish.name()\n })),\n src,\n style: {\n animationDuration: vnode.attrs.animationDuration || '10s'\n },\n draggable: false // Without this, browsers somehow drag the image URL across windows instead of the drop data\n });\n }\n return m('.catchthefish-no-image', app.translator.trans(translationPrefix + 'missing'));\n }\n}\nflarum.reg.add('xxb-catch-the-fish', 'forum/components/FishImage', FishImage);","import app from 'flarum/forum/app';\nimport FishImage from './FishImage';\nconst translationPrefix = 'xxb-catch-the-fish.forum.basket.';\nexport default class Basket {\n view() {\n if (!app.session.user) {\n return m('div');\n }\n const basket = app.session.user.catchTheFishBasket();\n if (!basket) {\n return m('div');\n }\n const fishesThatCanBePlaced = basket.filter(fish => fish.canPlace());\n if (fishesThatCanBePlaced.length === 0) {\n return m('div');\n }\n return m('.catchthefish-basket', [m('.catchthefish-basket-title', app.translator.trans(translationPrefix + 'title')), m('p', app.translator.trans(translationPrefix + 'drag-help')), fishesThatCanBePlaced.map(fish => m('.catchthefish-basket-entry', [m('.catchthefish-basket-fish', {\n draggable: true,\n ondragstart(event) {\n if (!event.dataTransfer) {\n return;\n }\n\n // Used for internal drag and drop animation\n app.draggedFishId = fish.id();\n\n // Used for cross-window drag and drop\n // Chrome doesn't allow reading this value in ondragover so we can't show a drop area in that situation\n // (we would have to show a drop area for all text drops, which is too broad)\n event.dataTransfer.setData('text/plain', 'fish:' + fish.id());\n }\n }, m(FishImage, {\n fish\n })), m('.catchthefish-basket-time', app.translator.trans(translationPrefix + 'time', {\n time: dayjs(fish.placeUntil()).fromNow()\n }))]))]);\n }\n}\nflarum.reg.add('xxb-catch-the-fish', 'forum/components/Basket', Basket);","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'forum/components/CommentPost');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'forum/components/UserCard');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'forum/components/DiscussionHero');","import app from 'flarum/forum/app';\nexport default class DropArea {\n view() {\n return m('.catchthefish-drop-area', app.translator.trans('xxb-catch-the-fish.forum.drop-area.message'));\n }\n}\nflarum.reg.add('xxb-catch-the-fish', 'forum/components/DropArea', DropArea);","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/utils/classList');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/components/Modal');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/components/Button');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/components/Link');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/components/Avatar');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/helpers/username');","import app from 'flarum/forum/app';\nimport Link from 'flarum/common/components/Link';\nimport Avatar from 'flarum/common/components/Avatar';\nimport username from 'flarum/common/helpers/username';\nexport default class User {\n view(vnode) {\n const user = vnode.attrs.user;\n return m(Link, {\n href: app.route.user(user)\n }, [m(Avatar, {\n user\n }), ' ', username(user)]);\n }\n}\nflarum.reg.add('xxb-catch-the-fish', 'forum/components/User', User);","import _defineProperty from \"@babel/runtime/helpers/esm/defineProperty\";\nimport app from 'flarum/forum/app';\nimport Modal from 'flarum/common/components/Modal';\nimport Button from 'flarum/common/components/Button';\nimport FishImage from '../components/FishImage';\nimport User from '../components/User';\nconst translationPrefix = 'xxb-catch-the-fish.forum.caught-fish-modal.';\nexport default class CaughtFishModal extends Modal {\n constructor() {\n super(...arguments);\n _defineProperty(this, \"newName\", void 0);\n _defineProperty(this, \"dirty\", false);\n _defineProperty(this, \"loading\", false);\n }\n oninit(vnode) {\n super.oninit(vnode);\n this.newName = this.attrs.fish.name();\n }\n className() {\n return 'Modal--small catchthefish-catch-modal';\n }\n title() {\n return app.translator.trans(translationPrefix + 'title');\n }\n saveNameAndPlacement(randomPlacement) {\n if (randomPlacement === void 0) {\n randomPlacement = false;\n }\n const body = {};\n if (this.dirty) {\n body.name = this.newName;\n }\n if (randomPlacement) {\n body.placement = 'random';\n }\n if (body) {\n this.loading = true;\n app.request({\n method: 'POST',\n url: app.forum.attribute('apiUrl') + '/catch-the-fish/fishes/' + this.attrs.fish.id() + '/place',\n body\n }).then(result => {\n app.store.pushPayload(result);\n this.hide();\n if (this.attrs.fish.canPlace() && !randomPlacement) {\n // Refresh basket by reloading user\n app.store.find('users', app.session.user.id()).then(() => {\n m.redraw();\n });\n }\n }).catch(err => {\n this.loading = false;\n m.redraw();\n throw err;\n });\n } else {\n this.hide();\n if (this.attrs.fish.canPlace() && !randomPlacement) {\n // Refresh basket by reloading user\n app.store.find('users', app.session.user.id()).then(() => {\n m.redraw();\n });\n }\n }\n }\n content() {\n var _myRanking;\n const fish = this.attrs.fish;\n const namedBy = fish.namedBy();\n const placedBy = fish.placedBy();\n return m('.Modal-body', [m('h3', '\"' + fish.name() + '\"'), m(FishImage, {\n fish\n }), namedBy ? m('p', [app.translator.trans(translationPrefix + 'named-by'), ' ', m(User, {\n user: namedBy\n })]) : null, placedBy ? m('p', [app.translator.trans(translationPrefix + 'placed-by'), ' ', m(User, {\n user: placedBy\n })]) : null, m('p', app.translator.trans(translationPrefix + 'congratulation', {\n catch_count: (_myRanking = fish.round().myRanking()) == null ? void 0 : _myRanking.catch_count()\n })), fish.canName() ? m('.Form-group', [m('p', app.translator.trans(translationPrefix + 'name-help')), m('label', app.translator.trans(translationPrefix + 'name')), m('input.FormControl', {\n value: this.newName,\n oninput: event => {\n this.newName = event.target.value;\n this.dirty = true;\n }\n })]) : null, fish.canPlace() ? m('p', app.translator.trans(translationPrefix + 'placement-help')) : null, m('.Form-group', Button.component({\n className: 'Button Button--primary Button--block',\n type: 'button',\n loading: this.loading,\n onclick: () => {\n this.saveNameAndPlacement();\n }\n }, app.translator.trans(translationPrefix + (this.dirty ? fish.canPlace() ? 'submit-name-place-later' : 'submit-name' : fish.canPlace() ? 'submit-place-later' : 'submit-continue')))), fish.canPlace() ? m('.Form-group', Button.component({\n className: 'Button Button--primary Button--block',\n type: 'button',\n loading: this.loading,\n onclick: () => {\n this.saveNameAndPlacement(true);\n }\n }, app.translator.trans(translationPrefix + (this.dirty ? 'submit-name-place-random' : 'submit-place-random')))) : null]);\n }\n onsubmit(event) {\n event.preventDefault();\n // Because the modal has its own form, pressing enter will submit here\n // In this case we apply the same feature as the first button\n this.saveNameAndPlacement();\n }\n}\nflarum.reg.add('xxb-catch-the-fish', 'forum/modals/CaughtFishModal', CaughtFishModal);","import _defineProperty from \"@babel/runtime/helpers/esm/defineProperty\";\nimport app from 'flarum/forum/app';\nimport classList from 'flarum/common/utils/classList';\nimport FishImage from './FishImage';\nimport CaughtFishModal from '../modals/CaughtFishModal';\nexport default class MovingFish {\n constructor() {\n _defineProperty(this, \"reverseAnimation\", void 0);\n _defineProperty(this, \"animationDuration\", void 0);\n }\n oninit() {\n this.reverseAnimation = Math.random() > 0.5;\n this.animationDuration = 5 + Math.floor(Math.random() * 70) / 10 + 's';\n }\n view(vnode) {\n const fish = vnode.attrs.fish;\n return m('.catchthefish-moving-fish', {\n className: classList({\n 'catchthefish-animate-flip': app.forum.catchTheFishAnimateFlip(),\n 'catchthefish-animate-reverse': this.reverseAnimation\n }),\n style: {\n animationDuration: this.animationDuration\n },\n onclick: () => {\n if (!fish.canCatch() && !app.session.user) {\n alert(app.translator.trans('xxb-catch-the-fish.forum.moving-fish.login'));\n return;\n }\n app.request({\n method: 'POST',\n url: app.forum.attribute('apiUrl') + '/catch-the-fish/fishes/' + fish.id() + '/catch',\n body: fish.placement()\n }).then(result => {\n app.modal.show(CaughtFishModal, {\n fish: app.store.pushPayload(result)\n });\n\n // So the parent can remove the fish from the relationship\n if (vnode.attrs.oncatch) {\n vnode.attrs.oncatch();\n }\n });\n }\n }, [m('.catchthefish-name', fish.name()), m(FishImage, {\n fish,\n animationDuration: this.animationDuration\n })]);\n }\n}\nflarum.reg.add('xxb-catch-the-fish', 'forum/components/MovingFish', MovingFish);","import { extend, override } from 'flarum/common/extend';\nimport app from 'flarum/forum/app';\nimport CommentPost from 'flarum/forum/components/CommentPost';\nimport UserCard from 'flarum/forum/components/UserCard';\nimport DiscussionHero from 'flarum/forum/components/DiscussionHero';\nimport DropArea from './components/DropArea';\nimport MovingFish from './components/MovingFish';\nfunction fishIdFromEvent(event) {\n if (!event.dataTransfer) {\n return null;\n }\n const data = event.dataTransfer.getData(\"text/plain\");\n const match = /^fish:([0-9]+)$/.exec(data);\n return match ? match[1] : null;\n}\nfunction movingFishContent(dragover, model) {\n const content = [];\n if (dragover) {\n content.push(m(DropArea));\n }\n const fishes = model.catchTheFishFishes();\n if (fishes) {\n fishes.forEach(fish => {\n if (!fish.canSee()) {\n return;\n }\n\n // Remove fish from relationship\n content.push(m(MovingFish, {\n fish,\n oncatch: () => {\n model.pushData({\n relationships: {\n catchTheFishFishes: {\n data: model.data.relationships.catchTheFishFishes.data.filter(f => f.id !== fish.id())\n }\n }\n });\n }\n }));\n });\n }\n return content;\n}\nfunction addDropAttrs(attrs, modelProperty) {\n attrs.ondrop = event => {\n this.fishDragOver = false;\n const fishId = app.draggedFishId || fishIdFromEvent(event);\n if (fishId) {\n event.preventDefault();\n const fish = app.store.getById('catchthefish-fishes', fishId);\n if (fish) {\n const placement = {};\n placement[modelProperty + '_id'] = this.attrs[modelProperty].id();\n app.request({\n method: 'POST',\n url: app.forum.attribute('apiUrl') + '/catch-the-fish/fishes/' + fish.id() + '/place',\n body: {\n placement\n }\n }).then(result => {\n app.store.pushPayload(result);\n\n // Refresh basket by reloading user\n app.store.find('users', app.session.user.id()).then(() => {\n m.redraw();\n });\n });\n } else {\n alert(app.translator.trans('xxb-catch-the-fish.forum.drop-area.missing-from-store'));\n }\n }\n };\n attrs.ondragover = event => {\n if (app.draggedFishId || fishIdFromEvent(event)) {\n event.preventDefault();\n event.dataTransfer.dropEffect = 'move';\n if (this.fishDragOver) {\n event.redraw = false;\n } else {\n this.fishDragOver = true;\n m.redraw();\n }\n } else {\n // In order to still support drag and drop across windows, we will accept any text drops\n // This is necessary because browsers don't give access to the drop value until the actual drop\n // But if we don't call preventDefault() here the drop can't happen in the first place\n if (event.dataTransfer && event.dataTransfer.types.includes('text/plain')) {\n event.preventDefault();\n }\n event.redraw = false;\n }\n };\n attrs.ondragenter = event => {\n event.preventDefault();\n event.redraw = false;\n };\n attrs.ondragleave = event => {\n if (this.fishDragOver) {\n this.fishDragOver = false;\n m.redraw();\n } else {\n event.redraw = false;\n }\n };\n}\nfunction addAreaToComponent(component, viewName, modelProperty) {\n if (viewName === 'content') {\n override(component, viewName, function (original) {\n return original().concat(movingFishContent(this.fishDragOver, this.attrs[modelProperty]));\n });\n } else {\n extend(component, viewName, function (items) {\n items.add('catchthefish-fish-and-drop', movingFishContent(this.fishDragOver, this.attrs[modelProperty]));\n });\n }\n extend(component, 'oninit', function () {\n this.fishDragOver = false;\n\n // Add a condition to the post tree retainer\n if (this.subtree) {\n this.subtree.check(() => this.fishDragOver);\n }\n });\n if (viewName === 'content') {\n // CommentPost has an attrs() method we can extend\n extend(component, 'elementAttrs', function (attrs) {\n addDropAttrs.bind(this)(attrs, modelProperty);\n });\n } else {\n // For other elements we manually add attrs to the vdom of the view\n extend(component, 'view', function (vdom) {\n vdom.attrs = vdom.attrs || {};\n addDropAttrs.bind(this)(vdom.attrs, modelProperty);\n });\n }\n}\nexport default function () {\n addAreaToComponent(CommentPost.prototype, 'content', 'post');\n addAreaToComponent(UserCard.prototype, 'infoItems', 'user');\n addAreaToComponent(DiscussionHero.prototype, 'items', 'discussion');\n document.addEventListener('dragend', () => {\n app.draggedFishId = null;\n });\n}","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/components/Page');","import _defineProperty from \"@babel/runtime/helpers/esm/defineProperty\";\nimport app from 'flarum/forum/app';\nimport User from './User';\nconst translationPrefix = 'xxb-catch-the-fish.forum.table-ranking.';\nexport default class RoundRankings {\n constructor() {\n _defineProperty(this, \"rankings\", null);\n }\n oninit(vnode) {\n app.request({\n method: 'GET',\n url: app.forum.attribute('apiUrl') + '/catch-the-fish/rounds/' + vnode.attrs.round.id() + '/rankings'\n }).then(result => {\n this.rankings = app.store.pushPayload(result);\n m.redraw();\n });\n }\n view(vnode) {\n if (this.rankings === null) {\n return m('p', app.translator.trans(translationPrefix + 'loading'));\n }\n return m('div', [m('h2', app.translator.trans(translationPrefix + 'title', {\n name: vnode.attrs.round.name()\n })), m('table.catchthefish-table', [m('thead', m('tr', [m('th', app.translator.trans(translationPrefix + 'rank')), m('th', app.translator.trans(translationPrefix + 'count')), m('th', app.translator.trans(translationPrefix + 'user'))])), m('tbody', this.rankings.map((ranking, index) => m('tr', [m('td', '#' + (index + 1)), m('td', ranking.catch_count()), m('td', m(User, {\n user: ranking.user()\n }))])))])]);\n }\n}\nflarum.reg.add('xxb-catch-the-fish', 'forum/components/RoundRankings', RoundRankings);","import app from 'flarum/forum/app';\nimport Page from 'flarum/common/components/Page';\nimport RoundRankings from \"../components/RoundRankings\";\nconst translationPrefix = 'xxb-catch-the-fish.forum.page-ranking.';\nexport default class Rankings extends Page {\n view() {\n if (!app.forum.catchTheFishCanSeeRankingsPage()) {\n return m('.container', m('p', app.translator.trans(translationPrefix + 'permission')));\n }\n const rounds = app.forum.catchTheFishActiveRounds();\n return m('.container', [m('h2', app.translator.trans(translationPrefix + 'title')), rounds ? rounds.map(round => m(RoundRankings, {\n round\n })) : m('p', app.translator.trans(translationPrefix + 'nothing'))]);\n }\n}\nflarum.reg.add('xxb-catch-the-fish', 'forum/pages/Rankings', Rankings);","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/components/LinkButton');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/components/Switch');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/utils/withAttr');","import _defineProperty from \"@babel/runtime/helpers/esm/defineProperty\";\nimport app from 'flarum/forum/app';\nimport Button from 'flarum/common/components/Button';\nimport Switch from 'flarum/common/components/Switch';\nimport extractText from 'flarum/common/utils/extractText';\nimport withAttr from 'flarum/common/utils/withAttr';\nconst translationPrefix = 'xxb-catch-the-fish.forum.edit-round.';\nexport default class EditRound {\n constructor() {\n _defineProperty(this, \"round\", void 0);\n _defineProperty(this, \"dirty\", false);\n _defineProperty(this, \"processing\", false);\n }\n oninit(vnode) {\n let round = vnode.attrs.round;\n if (typeof round === 'undefined') {\n round = app.store.createRecord('catchthefish-rounds', {\n attributes: {\n name: '',\n starts_at: '',\n ends_at: dayjs().add(1, 'day').toISOString(),\n include_starting_pack: true\n }\n });\n }\n this.round = round;\n }\n view(vnode) {\n return m('form', {\n onsubmit: event => {\n event.preventDefault();\n this.saveRecord(vnode);\n }\n }, [m('.Form-group', [m('label', app.translator.trans(translationPrefix + 'name')), m('input.FormControl', {\n value: this.round.name(),\n oninput: withAttr('value', this.updateAttribute.bind(this, 'name'))\n }), m('.helpText', app.translator.trans(translationPrefix + 'name-help'))]), m('.Form-group', [m('label', app.translator.trans(translationPrefix + 'starts-at')), m('input.FormControl', {\n value: this.round.starts_at(),\n oninput: withAttr('value', this.updateAttribute.bind(this, 'starts_at'))\n })]), m('.Form-group', [m('label', app.translator.trans(translationPrefix + 'ends-at')), m('input.FormControl', {\n value: this.round.ends_at(),\n oninput: withAttr('value', this.updateAttribute.bind(this, 'ends_at'))\n })]), this.round.exists ? '' : m('.Form-group', [m('label', [Switch.component({\n state: this.round.include_starting_pack(),\n onchange: this.updateAttribute.bind(this, 'include_starting_pack')\n }, app.translator.trans(translationPrefix + 'starting-pack'))]), m('.helpText', app.translator.trans(translationPrefix + 'starting-pack-help'))]), m('.ButtonGroup', [Button.component({\n type: 'submit',\n className: 'Button Button--primary',\n loading: this.processing,\n disabled: !this.readyToSave()\n }, app.translator.trans(translationPrefix + (this.round.exists ? 'save' : 'create'))), this.round.exists ? Button.component({\n type: 'button',\n className: 'Button Button--danger',\n loading: this.processing,\n onclick: () => {\n this.deleteRecord(vnode);\n }\n }, app.translator.trans(translationPrefix + 'delete')) : ''])]);\n }\n updateAttribute(attribute, value) {\n this.round.pushAttributes({\n [attribute]: value\n });\n this.dirty = true;\n }\n readyToSave() {\n if (!this.round.name()) {\n return false;\n }\n return this.dirty;\n }\n saveRecord(vnode) {\n this.processing = true;\n this.round.save(this.round.data.attributes).then(() => {\n if (vnode.attrs.onsave) {\n vnode.attrs.onsave();\n }\n this.processing = false;\n this.dirty = false;\n m.redraw();\n }).catch(err => {\n this.processing = false;\n m.redraw();\n throw err;\n });\n }\n deleteRecord(vnode) {\n if (!confirm(extractText(app.translator.trans(translationPrefix + 'delete-confirmation', {\n name: this.round.name()\n })))) {\n return;\n }\n this.processing = true;\n this.round.delete().then(() => {\n if (vnode.attrs.ondelete) {\n vnode.attrs.ondelete();\n }\n this.processing = false;\n m.redraw();\n }).catch(err => {\n this.processing = false;\n m.redraw();\n throw err;\n });\n }\n}\nflarum.reg.add('xxb-catch-the-fish', 'forum/components/EditRound', EditRound);","import app from 'flarum/forum/app';\nimport Modal from 'flarum/common/components/Modal';\nimport EditRound from '../components/EditRound';\nconst translationPrefix = 'xxb-catch-the-fish.forum.new-round-modal.';\nexport default class NewRoundModal extends Modal {\n className() {\n return 'Modal--small';\n }\n title() {\n return app.translator.trans(translationPrefix + 'title');\n }\n content() {\n return m('.Modal-body', m(EditRound, {\n onsave: () => {\n this.hide();\n if (this.attrs.oncreateordelete) {\n this.attrs.oncreateordelete();\n }\n }\n }));\n }\n}\nflarum.reg.add('xxb-catch-the-fish', 'forum/modals/NewRoundModal', NewRoundModal);","import app from 'flarum/forum/app';\nimport Modal from 'flarum/common/components/Modal';\nimport EditRound from '../components/EditRound';\nconst translationPrefix = 'xxb-catch-the-fish.forum.edit-round-modal.';\nexport default class EditRoundModal extends Modal {\n className() {\n return 'Modal--small';\n }\n title() {\n return app.translator.trans(translationPrefix + 'title', {\n name: this.attrs.round.name()\n });\n }\n content() {\n return m('.Modal-body', m(EditRound, {\n round: this.attrs.round,\n ondelete: () => {\n this.hide();\n if (this.attrs.oncreateordelete) {\n this.attrs.oncreateordelete();\n }\n }\n }));\n }\n}\nflarum.reg.add('xxb-catch-the-fish', 'forum/modals/EditRoundModal', EditRoundModal);","import _defineProperty from \"@babel/runtime/helpers/esm/defineProperty\";\nimport app from 'flarum/forum/app';\nimport Page from 'flarum/common/components/Page';\nimport Button from 'flarum/common/components/Button';\nimport LinkButton from 'flarum/common/components/LinkButton';\nimport NewRoundModal from '../modals/NewRoundModal';\nimport EditRoundModal from '../modals/EditRoundModal';\nconst translationPrefix = 'xxb-catch-the-fish.forum.table-round.';\nexport default class RoundList extends Page {\n constructor() {\n super(...arguments);\n _defineProperty(this, \"rounds\", null);\n }\n oninit(vnode) {\n super.oninit(vnode);\n this.refreshRounds();\n }\n refreshRounds() {\n this.rounds = null;\n app.request({\n method: 'GET',\n url: app.forum.attribute('apiUrl') + '/catch-the-fish/rounds'\n }).then(result => {\n this.rounds = app.store.pushPayload(result);\n m.redraw();\n });\n }\n view() {\n if (this.rounds === null) {\n return m('.container', m('p', app.translator.trans(translationPrefix + 'loading')));\n }\n return m('.container', [m('h2', app.translator.trans(translationPrefix + 'title')), Button.component({\n className: 'Button Button--primary',\n onclick: () => {\n app.modal.show(NewRoundModal, {\n oncreateordelete: () => {\n this.refreshRounds();\n }\n });\n }\n }, app.translator.trans(translationPrefix + 'new')), m('table.catchthefish-table', [m('thead', m('tr', [m('th', app.translator.trans(translationPrefix + 'name')), m('th', app.translator.trans(translationPrefix + 'start')), m('th', app.translator.trans(translationPrefix + 'end')), m('th', app.translator.trans(translationPrefix + 'actions'))])), m('tbody', this.rounds.map(round => m('tr', [m('td', round.name()), m('td', round.starts_at()), m('td', round.ends_at()), m('td', [Button.component({\n className: 'Button',\n onclick: () => {\n app.modal.show(EditRoundModal, {\n round,\n oncreateordelete: () => {\n this.refreshRounds();\n }\n });\n }\n }, app.translator.trans(translationPrefix + 'edit')), ' ', LinkButton.component({\n className: 'Button',\n href: app.route('catchTheFishRound', {\n id: round.id()\n })\n }, app.translator.trans(translationPrefix + 'fishes'))])])))])]);\n }\n}\nflarum.reg.add('xxb-catch-the-fish', 'forum/pages/RoundList', RoundList);","import _defineProperty from \"@babel/runtime/helpers/esm/defineProperty\";\nimport app from 'flarum/forum/app';\nimport Button from 'flarum/common/components/Button';\nimport extractText from 'flarum/common/utils/extractText';\nconst translationPrefix = 'xxb-catch-the-fish.forum.edit-fish.';\nexport default class EditFish {\n constructor() {\n _defineProperty(this, \"fish\", void 0);\n _defineProperty(this, \"dirty\", false);\n _defineProperty(this, \"processing\", false);\n }\n oninit(vnode) {\n let fish = vnode.attrs.fish;\n if (typeof fish === 'undefined') {\n fish = app.store.createRecord('catchthefish-fishes', {\n attributes: {\n round_id: vnode.attrs.round.id(),\n name: ''\n }\n });\n }\n this.fish = fish;\n }\n view(vnode) {\n return m('form', {\n onsubmit: event => {\n event.preventDefault();\n this.saveRecord(vnode);\n }\n }, [m('.Form-group', [m('label', app.translator.trans(translationPrefix + 'name')), m('input.FormControl', {\n value: this.fish.name(),\n oninput: event => {\n this.fish.pushAttributes({\n name: event.target.value\n });\n this.dirty = true;\n }\n }), m('.helpText', app.translator.trans(translationPrefix + 'name-help'))]), m('.ButtonGroup', [Button.component({\n type: 'submit',\n className: 'Button Button--primary',\n loading: this.processing,\n disabled: !this.readyToSave()\n }, app.translator.trans(translationPrefix + (this.fish.exists ? 'save' : 'create'))), this.fish.exists ? Button.component({\n type: 'button',\n className: 'Button Button--danger',\n loading: this.processing,\n onclick: () => {\n this.deleteRecord(vnode);\n }\n }, app.translator.trans(translationPrefix + 'delete')) : ''])]);\n }\n readyToSave() {\n if (!this.fish.name()) {\n return false;\n }\n return this.dirty;\n }\n saveRecord(vnode) {\n this.processing = true;\n this.fish.save(this.fish.data.attributes).then(() => {\n if (vnode.attrs.onsave) {\n vnode.attrs.onsave();\n }\n this.processing = false;\n this.dirty = false;\n m.redraw();\n }).catch(err => {\n this.processing = false;\n m.redraw();\n throw err;\n });\n }\n deleteRecord(vnode) {\n if (!confirm(extractText(app.translator.trans(translationPrefix + 'delete-confirmation', {\n name: this.fish.name()\n })))) {\n return;\n }\n this.processing = true;\n this.fish.delete().then(() => {\n if (vnode.attrs.ondelete) {\n vnode.attrs.ondelete();\n }\n this.processing = false;\n m.redraw();\n }).catch(err => {\n this.processing = false;\n m.redraw();\n throw err;\n });\n }\n}\nflarum.reg.add('xxb-catch-the-fish', 'forum/components/EditFish', EditFish);","import app from 'flarum/forum/app';\nimport Modal from 'flarum/common/components/Modal';\nimport EditFish from '../components/EditFish';\nconst translationPrefix = 'xxb-catch-the-fish.forum.new-fish-modal.';\nexport default class NewFishModal extends Modal {\n className() {\n return 'Modal--small';\n }\n title() {\n return app.translator.trans(translationPrefix + 'title');\n }\n content() {\n return m('.Modal-body', m(EditFish, {\n round: this.attrs.round,\n onsave: () => {\n this.hide();\n if (this.attrs.oncreateordelete) {\n this.attrs.oncreateordelete();\n }\n }\n }));\n }\n}\nflarum.reg.add('xxb-catch-the-fish', 'forum/modals/NewFishModal', NewFishModal);","import app from 'flarum/forum/app';\nimport Modal from 'flarum/common/components/Modal';\nimport EditFish from '../components/EditFish';\nconst translationPrefix = 'xxb-catch-the-fish.forum.edit-fish-modal.';\nexport default class EditRoundModal extends Modal {\n className() {\n return 'Modal--small';\n }\n title() {\n return app.translator.trans(translationPrefix + 'title', {\n name: this.attrs.fish.name()\n });\n }\n content() {\n return m('.Modal-body', m(EditFish, {\n fish: this.attrs.fish,\n ondelete: () => {\n this.hide();\n if (this.attrs.oncreateordelete) {\n this.attrs.oncreateordelete();\n }\n }\n }));\n }\n}\nflarum.reg.add('xxb-catch-the-fish', 'forum/modals/EditFishModal', EditRoundModal);","import _defineProperty from \"@babel/runtime/helpers/esm/defineProperty\";\nimport app from 'flarum/forum/app';\nimport Link from 'flarum/common/components/Link';\nimport Page from 'flarum/common/components/Page';\nimport Button from 'flarum/common/components/Button';\nimport Discussion from 'flarum/common/models/Discussion';\nimport Post from 'flarum/common/models/Post';\nimport UserModel from 'flarum/common/models/User';\nimport NewFishModal from '../modals/NewFishModal';\nimport EditFishModal from '../modals/EditFishModal';\nimport FishImage from '../components/FishImage';\nimport User from '../components/User';\nconst translationPrefix = 'xxb-catch-the-fish.forum.table-fish.';\nexport default class RoundSettings extends Page {\n constructor() {\n super(...arguments);\n _defineProperty(this, \"roundId\", void 0);\n _defineProperty(this, \"round\", null);\n _defineProperty(this, \"fishes\", null);\n _defineProperty(this, \"uploading\", false);\n }\n oninit(vnode) {\n super.oninit(vnode);\n this.roundId = m.route.param('id');\n this.round = app.store.getById('catchthefish-rounds', this.roundId);\n if (!this.round) {\n app.request({\n method: 'GET',\n url: app.forum.attribute('apiUrl') + '/catch-the-fish/rounds/' + this.roundId\n }).then(result => {\n this.round = app.store.pushPayload(result);\n m.redraw();\n });\n }\n this.refreshFishes();\n }\n refreshFishes() {\n this.fishes = null;\n app.request({\n method: 'GET',\n url: app.forum.attribute('apiUrl') + '/catch-the-fish/rounds/' + this.roundId + '/fishes'\n }).then(result => {\n this.fishes = app.store.pushPayload(result);\n m.redraw();\n });\n }\n\n // Based on Flarum's AvatarEditor component\n openPicker(callback, multiple) {\n if (multiple === void 0) {\n multiple = false;\n }\n if (this.uploading) return;\n const $input = $('');\n // @ts-ignore should the variable be cast to string?\n $input.attr('multiple', multiple);\n $input.appendTo('body').hide().click().on('change', event => {\n callback(event.target.files);\n });\n }\n uploadImages(images, fish) {\n const body = new FormData();\n if (fish) {\n body.append('image', images[0]);\n } else {\n for (let i = 0; i < images.length; i++) {\n body.append('image' + i, images[i]);\n }\n }\n this.uploading = true;\n m.redraw();\n app.request({\n method: 'POST',\n url: app.forum.attribute('apiUrl') + '/catch-the-fish/' + (fish ? 'fishes/' + fish.id() + '/image' : 'rounds/' + this.roundId + '/fishes-from-images'),\n serialize: raw => raw,\n body\n }).then(() => {\n this.uploading = false;\n m.redraw();\n this.refreshFishes();\n }).catch(err => {\n this.uploading = false;\n m.redraw();\n throw err;\n });\n }\n view() {\n if (!this.round || this.fishes === null) {\n return m('.container', m('p', app.translator.trans(translationPrefix + 'loading')));\n }\n return m('.container', [m('h2', app.translator.trans(translationPrefix + 'title', {\n name: this.round.name()\n })), Button.component({\n className: 'Button Button--primary',\n onclick: () => {\n app.modal.show(NewFishModal, {\n round: this.round,\n oncreateordelete: () => {\n this.refreshFishes();\n }\n });\n }\n }, app.translator.trans(translationPrefix + 'new')), ' ', Button.component({\n className: 'Button',\n type: 'button',\n onclick: () => {\n this.openPicker(files => {\n this.uploadImages(files);\n }, true);\n },\n loading: this.uploading\n }, app.translator.trans(translationPrefix + 'new-from-image')), m('table.catchthefish-table', [m('thead', m('tr', [m('th', app.translator.trans(translationPrefix + 'image')), m('th', app.translator.trans(translationPrefix + 'name')), m('th', app.translator.trans(translationPrefix + 'user-name')), m('th', app.translator.trans(translationPrefix + 'user-place')), m('th', app.translator.trans(translationPrefix + 'placement')), m('th', app.translator.trans(translationPrefix + 'actions'))])), m('tbody', this.fishes.length === 0 ? m('tr', [m('td', 'No fishes')]) : this.fishes.map(fish => {\n const namedBy = fish.namedBy();\n const placedBy = fish.placedBy();\n return m('tr', [m('td', m(FishImage, {\n fish\n })), m('td', fish.name()), m('td', namedBy ? m(User, {\n user: namedBy\n }) : m('em', app.translator.trans(translationPrefix + 'no-user-name'))), m('td', placedBy ? m(User, {\n user: placedBy\n }) : m('em', app.translator.trans(translationPrefix + 'no-user-place'))), m('td', this.placement(fish)), m('td', [Button.component({\n className: 'Button',\n onclick: () => {\n app.modal.show(EditFishModal, {\n fish,\n oncreateordelete: () => {\n this.refreshFishes();\n }\n });\n }\n }, app.translator.trans(translationPrefix + 'edit')), ' ', Button.component({\n className: 'Button',\n type: 'button',\n onclick: () => {\n this.openPicker(files => {\n this.uploadImages(files, fish);\n });\n },\n loading: this.uploading\n }, app.translator.trans(translationPrefix + 'upload'))])]);\n }))])]);\n }\n placement(fish) {\n const placement = fish.placementModel();\n if (placement instanceof Discussion) {\n return m(Link, {\n href: app.route.discussion(placement)\n }, placement.title());\n }\n if (placement instanceof Post) {\n const discussion = placement.discussion();\n return m(Link, {\n href: app.route.post(placement)\n }, (discussion ? discussion.title() : '') + ' #' + placement.number());\n }\n if (placement instanceof UserModel) {\n return m(User, {\n user: placement\n });\n }\n return 'N/A';\n }\n}\nflarum.reg.add('xxb-catch-the-fish', 'forum/pages/RoundSettings', RoundSettings);","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'forum/components/IndexPage');","import { extend } from 'flarum/common/extend';\nimport app from 'flarum/forum/app';\nimport IndexPage from 'flarum/forum/components/IndexPage';\nimport LinkButton from 'flarum/common/components/LinkButton';\nconst translationPrefix = 'xxb-catch-the-fish.forum.nav.';\nexport default function () {\n extend(IndexPage.prototype, 'navItems', function (items) {\n if (app.forum.catchTheFishCanSeeRankingsPage()) {\n items.add('catchthefish-rankings', LinkButton.component({\n icon: 'fas fa-fish',\n href: app.route('catchTheFishRankings')\n }, app.translator.trans(translationPrefix + 'rankings')));\n }\n if (app.forum.catchTheFishCanModerate()) {\n items.add('catchthefish-settings', LinkButton.component({\n icon: 'fas fa-fish',\n href: app.route('catchTheFishRounds')\n }, app.translator.trans(translationPrefix + 'settings')));\n }\n });\n}","import app from 'flarum/forum/app';\nimport LinkButton from 'flarum/common/components/LinkButton';\nconst translationPrefix = 'xxb-catch-the-fish.forum.round-alert.';\nexport default class UpdateAlert {\n view(vnode) {\n return m('.Alert.Alert-info', m('.container', [m('span.Alert-body', [app.translator.trans(translationPrefix + 'message', {\n name: vnode.attrs.round.name(),\n time: dayjs(vnode.attrs.round.ends_at()).calendar()\n })]), app.forum.catchTheFishCanSeeRankingsPage() ? m('ul.Alert-controls', m('li', LinkButton.component({\n href: app.route('catchTheFishRankings')\n }, app.translator.trans(translationPrefix + 'rankings')))) : null]));\n }\n}\nflarum.reg.add('xxb-catch-the-fish', 'forum/components/RoundAlert', UpdateAlert);","import { override } from 'flarum/common/extend';\nimport app from 'flarum/forum/app';\nimport IndexPage from 'flarum/forum/components/IndexPage';\nimport RoundAlert from './components/RoundAlert';\nlet rounds = null;\nlet loading = false;\nexport default function () {\n override(IndexPage.prototype, 'hero', function (original) {\n const existing = original();\n if (app.forum.attribute('catchTheFishAlertRound') === false) {\n return existing;\n }\n if (!rounds) {\n if (!loading) {\n loading = true;\n app.request({\n method: 'GET',\n url: app.forum.attribute('apiUrl') + '/catch-the-fish/rounds',\n params: {\n page: {\n limit: 10\n }\n }\n }).then(result => {\n rounds = app.store.pushPayload(result);\n m.redraw();\n }).catch(() => {\n rounds = [];\n }).finally(() => {\n loading = false;\n });\n }\n return existing;\n }\n const additional = rounds.map(round => round ? m(RoundAlert, {\n round\n }) : null);\n if (Array.isArray(existing)) {\n return additional.concat(existing);\n }\n return m('div', [additional, existing]);\n });\n}","import app from 'flarum/forum/app';\nimport addModels from './addModels';\nimport addFishBasket from './addFishBasket';\nimport addDropAreas from './addDropAreas';\nimport addPages from './addPages';\nimport addNavLinks from './addNavLinks';\nimport addRoundAlert from './addRoundAlert';\nimport calendar from 'dayjs/plugin/calendar';\n\n// Import calendar plugin for Day.js\ndayjs.extend(calendar);\napp.initializers.add('xxb-catch-the-fish', () => {\n addModels();\n addFishBasket();\n addDropAreas();\n addPages();\n addNavLinks();\n addRoundAlert();\n});","import app from 'flarum/forum/app';\nimport Model from 'flarum/common/Model';\nimport Discussion from 'flarum/common/models/Discussion';\nimport Post from 'flarum/common/models/Post';\nimport User from 'flarum/common/models/User';\nimport Forum from 'flarum/common/models/Forum';\nimport Fish from './models/Fish';\nimport Ranking from './models/Ranking';\nimport Round from './models/Round';\nexport default function () {\n app.store.models['catchthefish-fishes'] = Fish;\n app.store.models['catchthefish-rankings'] = Ranking;\n app.store.models['catchthefish-rounds'] = Round;\n Discussion.prototype.catchTheFishFishes = Model.hasMany('catchTheFishFishes');\n Post.prototype.catchTheFishFishes = Model.hasMany('catchTheFishFishes');\n User.prototype.catchTheFishFishes = Model.hasMany('catchTheFishFishes');\n User.prototype.catchTheFishBasket = Model.hasMany('catchTheFishBasket');\n Forum.prototype.catchTheFishActiveRounds = Model.hasMany('catchTheFishActiveRounds');\n Forum.prototype.catchTheFishCanModerate = Model.attribute('catchTheFishCanModerate');\n Forum.prototype.catchTheFishCanSeeRankingsPage = Model.attribute('catchTheFishCanSeeRankingsPage');\n Forum.prototype.catchTheFishAnimateFlip = Model.attribute('catchTheFishAnimateFlip');\n}","import { extend } from 'flarum/common/extend';\nimport HeaderPrimary from 'flarum/forum/components/HeaderPrimary';\nimport Basket from './components/Basket';\nexport default function () {\n extend(HeaderPrimary.prototype, 'items', function (items) {\n items.add('catchthefish-basket', m(Basket));\n });\n}","import app from 'flarum/forum/app';\nimport Rankings from \"./pages/Rankings\";\nimport RoundList from './pages/RoundList';\nimport RoundSettings from './pages/RoundSettings';\nexport default function () {\n app.routes.catchTheFishRankings = {\n path: '/catch-the-fish',\n component: Rankings\n };\n app.routes.catchTheFishRounds = {\n path: '/catch-the-fish/rounds',\n component: RoundList\n };\n app.routes.catchTheFishRound = {\n path: '/catch-the-fish/rounds/:id',\n component: RoundSettings\n };\n}"],"names":["module","exports","e","t","a","n","d","lastDay","sameDay","nextDay","nextWeek","lastWeek","sameElse","prototype","calendar","this","$locale","o","startOf","s","diff","i","f","l","call","format","__webpack_module_cache__","__webpack_require__","moduleId","flarum","reg","_webpack_runtimes","cachedModule","undefined","__webpack_modules__","getter","__esModule","definition","Array","isArray","length","key","binding","Object","defineProperty","enumerable","value","get","obj","prop","hasOwnProperty","app_namespaceObject","Model_namespaceObject","Discussion_namespaceObject","Post_namespaceObject","User_namespaceObject","Forum_namespaceObject","_typeof","Symbol","iterator","constructor","_defineProperty","r","toPrimitive","TypeError","String","toPropertyKey","configurable","writable","Fish","Model_default","super","arguments","attribute","hasOne","apiEndpoint","exists","data","id","attributes","round_id","add","Ranking","Round","extend_namespaceObject","HeaderPrimary_namespaceObject","extractText_namespaceObject","translationPrefix","FishImage","view","vnode","src","attrs","fish","image_url","m","alt","extractText_default","app_default","translator","trans","name","style","animationDuration","draggable","Basket_translationPrefix","Basket","session","user","basket","catchTheFishBasket","fishesThatCanBePlaced","filter","canPlace","map","ondragstart","event","dataTransfer","draggedFishId","setData","time","dayjs","placeUntil","fromNow","CommentPost_namespaceObject","UserCard_namespaceObject","DiscussionHero_namespaceObject","DropArea","classList_namespaceObject","Modal_namespaceObject","Button_namespaceObject","Link_namespaceObject","Avatar_namespaceObject","username_namespaceObject","User","Link_default","href","route","Avatar_default","username_default","CaughtFishModal_translationPrefix","CaughtFishModal","Modal_default","oninit","newName","className","title","saveNameAndPlacement","randomPlacement","body","dirty","placement","loading","request","method","url","forum","then","result","store","pushPayload","hide","find","redraw","catch","err","content","_myRanking","namedBy","placedBy","catch_count","round","myRanking","canName","oninput","target","Button_default","component","type","onclick","onsubmit","preventDefault","MovingFish","reverseAnimation","Math","random","floor","classList_default","catchTheFishAnimateFlip","canCatch","modal","show","oncatch","alert","fishIdFromEvent","getData","match","exec","movingFishContent","dragover","model","push","fishes","catchTheFishFishes","forEach","canSee","pushData","relationships","addDropAttrs","modelProperty","ondrop","fishDragOver","fishId","getById","ondragover","dropEffect","types","includes","ondragenter","ondragleave","addAreaToComponent","viewName","override","original","concat","extend","items","subtree","check","bind","vdom","Page_namespaceObject","RoundRankings_translationPrefix","RoundRankings","rankings","ranking","index","Rankings_translationPrefix","Rankings","Page_default","catchTheFishCanSeeRankingsPage","rounds","catchTheFishActiveRounds","LinkButton_namespaceObject","Switch_namespaceObject","withAttr_namespaceObject","EditRound_translationPrefix","EditRound","createRecord","starts_at","ends_at","toISOString","include_starting_pack","saveRecord","withAttr_default","updateAttribute","Switch_default","state","onchange","processing","disabled","readyToSave","deleteRecord","pushAttributes","save","onsave","confirm","delete","ondelete","NewRoundModal","NewRoundModal_translationPrefix","oncreateordelete","EditRoundModal","EditRoundModal_translationPrefix","RoundList_translationPrefix","RoundList","refreshRounds","LinkButton_default","EditFish_translationPrefix","EditFish","NewFishModal","NewFishModal_translationPrefix","EditFishModal_EditRoundModal","EditFishModal_translationPrefix","RoundSettings_translationPrefix","RoundSettings","roundId","param","refreshFishes","openPicker","callback","multiple","uploading","$input","$","attr","appendTo","click","on","files","uploadImages","images","FormData","append","serialize","raw","placementModel","Discussion_default","discussion","Post_default","post","number","User_default","IndexPage_namespaceObject","addNavLinks_translationPrefix","RoundAlert_translationPrefix","UpdateAlert","calendar_default","initializers","models","hasMany","Forum_default","catchTheFishCanModerate","HeaderPrimary_default","CommentPost_default","UserCard_default","DiscussionHero_default","document","addEventListener","routes","catchTheFishRankings","path","catchTheFishRounds","catchTheFishRound","IndexPage_default","icon","existing","params","page","limit","finally","additional"],"sourceRoot":""} \ No newline at end of file diff --git a/js/src/forum/addDropAreas.ts b/js/src/forum/addDropAreas.ts new file mode 100644 index 0000000..b023cb7 --- /dev/null +++ b/js/src/forum/addDropAreas.ts @@ -0,0 +1,193 @@ +import { extend, override } from 'flarum/common/extend'; +import app from 'flarum/forum/app'; +import CommentPost from 'flarum/forum/components/CommentPost'; +import UserCard from 'flarum/forum/components/UserCard'; +import DiscussionHero from 'flarum/forum/components/DiscussionHero'; +import DropArea from './components/DropArea'; +import MovingFish from './components/MovingFish'; +function fishIdFromEvent(event) { + if (!event.dataTransfer) { + return null; + } + const data = event.dataTransfer.getData("text/plain"); + const match = /^fish:([0-9]+)$/.exec(data); + return match ? match[1] : null; +} +function selectedFishId() { + return app.draggedFishId || app.catchTheFishSelectedFishId || null; +} +function placeSelectedFish(model, modelProperty) { + const fishId = selectedFishId(); + if (!fishId) { + return; + } + const fish = app.store.getById('catchthefish-fishes', fishId); + if (!fish) { + alert(app.translator.trans('xxb-catch-the-fish.forum.drop-area.missing-from-store')); + return; + } + const placement = {}; + placement[modelProperty + '_id'] = model.id(); + app.request({ + method: 'POST', + url: app.forum.attribute('apiUrl') + '/catch-the-fish/fishes/' + fish.id() + '/place', + body: { + placement + } + }).then(result => { + app.store.pushPayload(result); + app.draggedFishId = null; + app.catchTheFishSelectedFishId = null; + app.store.find('users', app.session.user.id()).then(() => { + m.redraw(); + }); + }); +} +function movingFishContent(dragover, model) { + const content = []; + if (dragover || app.catchTheFishSelectedFishId) { + content.push(m(DropArea)); + } + const fishes = model.catchTheFishFishes(); + if (fishes) { + fishes.forEach(fish => { + if (!fish.canSee()) { + return; + } + + // Remove fish from relationship + content.push(m(MovingFish, { + fish, + oncatch: () => { + model.pushData({ + relationships: { + catchTheFishFishes: { + data: model.data.relationships.catchTheFishFishes.data.filter(f => f.id !== fish.id()) + } + } + }); + } + })); + }); + } + return content; +} +function addDropAttrs(attrs, modelProperty) { + attrs.ondrop = event => { + this.fishDragOver = false; + const fishId = app.draggedFishId || fishIdFromEvent(event); + if (fishId) { + event.preventDefault(); + const fish = app.store.getById('catchthefish-fishes', fishId); + if (fish) { + const placement = {}; + placement[modelProperty + '_id'] = this.attrs[modelProperty].id(); + app.request({ + method: 'POST', + url: app.forum.attribute('apiUrl') + '/catch-the-fish/fishes/' + fish.id() + '/place', + body: { + placement + } + }).then(result => { + app.store.pushPayload(result); + + // Refresh basket by reloading user + app.store.find('users', app.session.user.id()).then(() => { + m.redraw(); + }); + }); + } else { + alert(app.translator.trans('xxb-catch-the-fish.forum.drop-area.missing-from-store')); + } + } + }; + attrs.onclick = event => { + if (app.catchTheFishSelectedFishId) { + event.preventDefault(); + event.stopPropagation(); + placeSelectedFish(this.attrs[modelProperty], modelProperty); + } + }; + attrs.ondragover = event => { + if (app.draggedFishId || fishIdFromEvent(event)) { + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + if (this.fishDragOver) { + event.redraw = false; + } else { + this.fishDragOver = true; + m.redraw(); + } + } else { + // In order to still support drag and drop across windows, we will accept any text drops + // This is necessary because browsers don't give access to the drop value until the actual drop + // But if we don't call preventDefault() here the drop can't happen in the first place + if (event.dataTransfer && event.dataTransfer.types.includes('text/plain')) { + event.preventDefault(); + } + event.redraw = false; + } + }; + attrs.ondragenter = event => { + event.preventDefault(); + event.redraw = false; + }; + attrs.ondragleave = event => { + if (this.fishDragOver) { + this.fishDragOver = false; + m.redraw(); + } else { + event.redraw = false; + } + }; +} +function addAreaToComponent(component, viewName, modelProperty) { + if (viewName === 'content') { + override(component, viewName, function (original) { + return original().concat(movingFishContent(this.fishDragOver, this.attrs[modelProperty])); + }); + } else { + extend(component, viewName, function (items) { + items.add('catchthefish-fish-and-drop', movingFishContent(this.fishDragOver, this.attrs[modelProperty])); + }); + } + extend(component, 'oninit', function () { + this.fishDragOver = false; + + // Add a condition to the post tree retainer + if (this.subtree) { + this.subtree.check(() => this.fishDragOver); + } + }); + if (viewName === 'content') { + // CommentPost has an attrs() method we can extend + extend(component, 'elementAttrs', function (attrs) { + addDropAttrs.bind(this)(attrs, modelProperty); + }); + } else { + // For other elements we manually add attrs to the vdom of the view + extend(component, 'view', function (vdom) { + vdom.attrs = vdom.attrs || {}; + addDropAttrs.bind(this)(vdom.attrs, modelProperty); + }); + } +} +export default function () { + addAreaToComponent(CommentPost.prototype, 'content', 'post'); + addAreaToComponent(UserCard.prototype, 'infoItems', 'user'); + addAreaToComponent(DiscussionHero.prototype, 'items', 'discussion'); + extend(UserCard.prototype, 'oninit', function () { + const user = this.attrs.user; + if (user && user.exists && !user.data.relationships?.catchTheFishFishes && !user.catchTheFishReloading) { + user.catchTheFishReloading = true; + app.store.find('users', user.id()).then(() => { + m.redraw(); + }).finally(() => { + user.catchTheFishReloading = false; + }); + } + }); + document.addEventListener('dragend', () => { + app.draggedFishId = null; + }); +} diff --git a/js/src/forum/addFishBasket.ts b/js/src/forum/addFishBasket.ts new file mode 100644 index 0000000..630cfc3 --- /dev/null +++ b/js/src/forum/addFishBasket.ts @@ -0,0 +1,10 @@ +import Basket from './components/Basket'; +export default function () { + let container = document.querySelector('.catchthefish-basket-root'); + if (!container) { + container = document.createElement('div'); + container.className = 'catchthefish-basket-root'; + document.body.appendChild(container); + } + m.mount(container, Basket); +} diff --git a/js/src/forum/addNavLinks.ts b/js/src/forum/addNavLinks.ts new file mode 100644 index 0000000..02dfa4d --- /dev/null +++ b/js/src/forum/addNavLinks.ts @@ -0,0 +1,21 @@ +import { extend } from 'flarum/common/extend'; +import app from 'flarum/forum/app'; +import IndexSidebar from 'flarum/forum/components/IndexSidebar'; +import LinkButton from 'flarum/common/components/LinkButton'; +const translationPrefix = 'xxb-catch-the-fish.forum.nav.'; +export default function () { + extend(IndexSidebar.prototype, 'navItems', function (items) { + if (app.forum.catchTheFishCanSeeRankingsPage()) { + items.add('catchthefish-rankings', LinkButton.component({ + icon: 'fas fa-fish', + href: app.route('catchTheFishRankings') + }, app.translator.trans(translationPrefix + 'rankings'))); + } + if (app.forum.catchTheFishCanModerate()) { + items.add('catchthefish-settings', LinkButton.component({ + icon: 'fas fa-fish', + href: app.route('catchTheFishRounds') + }, app.translator.trans(translationPrefix + 'settings'))); + } + }); +} diff --git a/js/src/forum/components/Basket.ts b/js/src/forum/components/Basket.ts new file mode 100644 index 0000000..8bd9351 --- /dev/null +++ b/js/src/forum/components/Basket.ts @@ -0,0 +1,43 @@ +import app from 'flarum/forum/app'; +import FishImage from './FishImage'; +const translationPrefix = 'xxb-catch-the-fish.forum.basket.'; +export default class Basket { + view() { + if (!app.session || !app.session.user) { + return m('div'); + } + const basket = app.session.user.catchTheFishBasket(); + if (!basket) { + return m('div'); + } + const fishesThatCanBePlaced = basket.filter(fish => fish.canPlace()); + if (fishesThatCanBePlaced.length === 0) { + return m('div'); + } + return m('.catchthefish-basket', [m('.catchthefish-basket-title', app.translator.trans(translationPrefix + 'title')), m('p', app.translator.trans(translationPrefix + 'drag-help')), fishesThatCanBePlaced.map(fish => m('.catchthefish-basket-entry', [m('.catchthefish-basket-fish', { + className: app.catchTheFishSelectedFishId === fish.id() ? 'active' : '', + draggable: true, + onclick() { + app.catchTheFishSelectedFishId = app.catchTheFishSelectedFishId === fish.id() ? null : fish.id(); + }, + ondragstart(event) { + if (!event.dataTransfer) { + return; + } + + // Used for internal drag and drop animation + app.draggedFishId = fish.id(); + + // Used for cross-window drag and drop + // Chrome doesn't allow reading this value in ondragover so we can't show a drop area in that situation + // (we would have to show a drop area for all text drops, which is too broad) + event.dataTransfer.setData('text/plain', 'fish:' + fish.id()); + } + }, m(FishImage, { + fish + })), m('.catchthefish-basket-time', app.translator.trans(translationPrefix + 'time', { + time: dayjs(fish.placeUntil()).fromNow() + }))]))]); + } +} +flarum.reg.add('xxb-catch-the-fish', 'forum/components/Basket', Basket); diff --git a/js/src/forum/modals/CaughtFishModal.ts b/js/src/forum/modals/CaughtFishModal.ts new file mode 100644 index 0000000..132fe1c --- /dev/null +++ b/js/src/forum/modals/CaughtFishModal.ts @@ -0,0 +1,111 @@ +import _defineProperty from "@babel/runtime/helpers/esm/defineProperty"; +import app from 'flarum/forum/app'; +import Modal from 'flarum/common/components/Modal'; +import Button from 'flarum/common/components/Button'; +import FishImage from '../components/FishImage'; +import User from '../components/User'; +const translationPrefix = 'xxb-catch-the-fish.forum.caught-fish-modal.'; +export default class CaughtFishModal extends Modal { + constructor() { + super(...arguments); + _defineProperty(this, "newName", void 0); + _defineProperty(this, "dirty", false); + _defineProperty(this, "loading", false); + } + oninit(vnode) { + super.oninit(vnode); + this.newName = this.attrs.fish.name(); + } + className() { + return 'Modal--small catchthefish-catch-modal'; + } + title() { + return app.translator.trans(translationPrefix + 'title'); + } + saveNameAndPlacement(randomPlacement) { + if (randomPlacement === void 0) { + randomPlacement = false; + } + const body = {}; + if (this.dirty) { + body.name = this.newName; + } + if (randomPlacement) { + body.placement = 'random'; + } + if (body) { + this.loading = true; + app.request({ + method: 'POST', + url: app.forum.attribute('apiUrl') + '/catch-the-fish/fishes/' + this.attrs.fish.id() + '/place', + body + }).then(result => { + app.store.pushPayload(result); + this.hide(); + if (this.attrs.fish.canPlace() && !randomPlacement) { + // Refresh basket by reloading user + app.store.find('users', app.session.user.id()).then(() => { + m.redraw(); + }); + } + }).catch(err => { + this.loading = false; + m.redraw(); + throw err; + }); + } else { + this.hide(); + if (this.attrs.fish.canPlace() && !randomPlacement) { + // Refresh basket by reloading user + app.store.find('users', app.session.user.id()).then(() => { + m.redraw(); + }); + } + } + } + content() { + var _myRanking; + const fish = this.attrs.fish; + const namedBy = fish.namedBy(); + const placedBy = fish.placedBy(); + const round = fish.round(); + const myRanking = round && round.myRanking ? round.myRanking() : null; + const catchCount = round && typeof round.my_catch_count === 'function' ? round.my_catch_count() : (myRanking ? (typeof myRanking.catch_count === 'function' ? myRanking.catch_count() : (myRanking.data && myRanking.data.attributes && myRanking.data.attributes.catch_count) || myRanking.catch_count || 0) : 0); + return m('.Modal-body', [m('h3', '"' + fish.name() + '"'), m(FishImage, { + fish + }), namedBy ? m('p', [app.translator.trans(translationPrefix + 'named-by'), ' ', m(User, { + user: namedBy + })]) : null, placedBy ? m('p', [app.translator.trans(translationPrefix + 'placed-by'), ' ', m(User, { + user: placedBy + })]) : null, m('p', app.translator.trans(translationPrefix + 'congratulation', { + catch_count: catchCount + })), fish.canName() ? m('.Form-group', [m('p', app.translator.trans(translationPrefix + 'name-help')), m('label', app.translator.trans(translationPrefix + 'name')), m('input.FormControl', { + value: this.newName, + oninput: event => { + this.newName = event.target.value; + this.dirty = true; + } + })]) : null, fish.canPlace() ? m('p', app.translator.trans(translationPrefix + 'placement-help')) : null, m('.Form-group', Button.component({ + className: 'Button Button--primary Button--block', + type: 'button', + loading: this.loading, + onclick: () => { + this.saveNameAndPlacement(); + } + }, app.translator.trans(translationPrefix + (this.dirty ? fish.canPlace() ? 'submit-name-place-later' : 'submit-name' : fish.canPlace() ? 'submit-place-later' : 'submit-continue')))), fish.canPlace() ? m('.Form-group', Button.component({ + className: 'Button Button--primary Button--block', + type: 'button', + loading: this.loading, + onclick: () => { + this.saveNameAndPlacement(true); + } + }, app.translator.trans(translationPrefix + (this.dirty ? 'submit-name-place-random' : 'submit-place-random')))) : null]); + } + onsubmit(event) { + event.preventDefault(); + // Because the modal has its own form, pressing enter will submit here + // In this case we apply the same feature as the first button + this.saveNameAndPlacement(); + } +} +flarum.reg.add('xxb-catch-the-fish', 'forum/modals/CaughtFishModal', CaughtFishModal); diff --git a/js/src/forum/models/Round.ts b/js/src/forum/models/Round.ts new file mode 100644 index 0000000..7b8d39b --- /dev/null +++ b/js/src/forum/models/Round.ts @@ -0,0 +1,17 @@ +import _defineProperty from "@babel/runtime/helpers/esm/defineProperty"; +import Model from 'flarum/common/Model'; +export default class Round extends Model { + constructor() { + super(...arguments); + _defineProperty(this, "name", Model.attribute('name')); + _defineProperty(this, "starts_at", Model.attribute('starts_at')); + _defineProperty(this, "ends_at", Model.attribute('ends_at')); + _defineProperty(this, "include_starting_pack", Model.attribute('include_starting_pack')); + _defineProperty(this, "my_catch_count", Model.attribute('my_catch_count')); + _defineProperty(this, "myRanking", Model.hasOne('myRanking')); + } + apiEndpoint() { + return '/catch-the-fish/rounds' + (this.exists ? '/' + this.data.id : ''); + } +} +flarum.reg.add('xxb-catch-the-fish', 'forum/models/Round', Round); diff --git a/migrations/2019_04_01_01_create_catchthefish_rounds_table.php b/migrations/2019_04_01_01_create_catchthefish_rounds_table.php new file mode 100644 index 0000000..64f51b8 --- /dev/null +++ b/migrations/2019_04_01_01_create_catchthefish_rounds_table.php @@ -0,0 +1,19 @@ + function (Builder $schema) { + $schema->create('catchthefish_rounds', function (Blueprint $table) { + $table->increments('id'); + $table->string('name'); + $table->timestamp('starts_at')->nullable()->index(); + $table->timestamp('ends_at')->nullable()->index(); + $table->timestamps(); + }); + }, + 'down' => function (Builder $schema) { + $schema->dropIfExists('catchthefish_rounds'); + }, +]; diff --git a/migrations/2019_04_01_02_create_catchthefish_fishes_table.php b/migrations/2019_04_01_02_create_catchthefish_fishes_table.php new file mode 100644 index 0000000..a074fa2 --- /dev/null +++ b/migrations/2019_04_01_02_create_catchthefish_fishes_table.php @@ -0,0 +1,35 @@ + function (Builder $schema) { + $schema->create('catchthefish_fishes', function (Blueprint $table) { + $table->increments('id'); + $table->unsignedInteger('round_id'); + $table->unsignedInteger('discussion_id_placement')->nullable(); + $table->unsignedInteger('post_id_placement')->nullable(); + $table->unsignedInteger('user_id_placement')->nullable(); + $table->unsignedInteger('user_id_last_catch')->nullable(); // To determine which user is allowed to edit fish + $table->unsignedInteger('user_id_last_placement')->nullable(); // To show last user who placed fish + $table->unsignedInteger('user_id_last_naming')->nullable(); // To show last user who renamed fish + $table->timestamp('placement_valid_since')->nullable()->index(); // To automatically apply previously random placement when custom placement expires + $table->timestamp('last_caught_at')->nullable()->index(); + $table->string('name'); + $table->string('image')->nullable(); + $table->timestamps(); + + $table->foreign('round_id')->references('id')->on('catchthefish_rounds')->onDelete('cascade'); + $table->foreign('discussion_id_placement')->references('id')->on('discussions')->onDelete('set null'); + $table->foreign('post_id_placement')->references('id')->on('posts')->onDelete('set null'); + $table->foreign('user_id_placement')->references('id')->on('users')->onDelete('set null'); + $table->foreign('user_id_last_catch')->references('id')->on('users')->onDelete('set null'); + $table->foreign('user_id_last_placement')->references('id')->on('users')->onDelete('set null'); + $table->foreign('user_id_last_naming')->references('id')->on('users')->onDelete('set null'); + }); + }, + 'down' => function (Builder $schema) { + $schema->dropIfExists('catchthefish_fishes'); + }, +]; diff --git a/migrations/2019_04_01_03_create_catchthefish_rankings.php b/migrations/2019_04_01_03_create_catchthefish_rankings.php new file mode 100644 index 0000000..84801a0 --- /dev/null +++ b/migrations/2019_04_01_03_create_catchthefish_rankings.php @@ -0,0 +1,22 @@ + function (Builder $schema) { + $schema->create('catchthefish_rankings', function (Blueprint $table) { + $table->increments('id'); + $table->unsignedInteger('round_id'); + $table->unsignedInteger('user_id'); + $table->unsignedInteger('catch_count')->index(); + $table->timestamps(); + + $table->foreign('round_id')->references('id')->on('catchthefish_rounds')->onDelete('cascade'); + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); + }); + }, + 'down' => function (Builder $schema) { + $schema->dropIfExists('catchthefish_rankings'); + }, +]; diff --git a/migrations/2022_02_19_01_alter_catchthefish_rankings_add_unique.php b/migrations/2022_02_19_01_alter_catchthefish_rankings_add_unique.php new file mode 100644 index 0000000..1d5b130 --- /dev/null +++ b/migrations/2022_02_19_01_alter_catchthefish_rankings_add_unique.php @@ -0,0 +1,39 @@ + function (Builder $schema) { + // Merge any duplicate ranking that might exist in the database + $schema->getConnection()->table('catchthefish_rankings') + ->selectRaw('round_id, user_id, sum(catch_count) as catch_count_sum') + ->orderBy('round_id') + ->orderBy('user_id') + ->groupBy('round_id', 'user_id')->each(function ($duplicate) { + $first = Ranking::query() + ->where('round_id', $duplicate->round_id) + ->where('user_id', $duplicate->user_id) + ->first(); + + $first->catch_count = $duplicate->catch_count_sum; + $first->save(); + + Ranking::query() + ->where('round_id', $duplicate->round_id) + ->where('user_id', $duplicate->user_id) + ->where('id', '!=', $first->id) + ->delete(); + }); + + // Add missing unique constraint + $schema->table('catchthefish_rankings', function (Blueprint $table) { + $table->unique(['round_id', 'user_id']); + }); + }, + 'down' => function (Builder $schema) { + // Not implemented because in Flarum you can't revert just one migration + // And it causes an error with the foreign key indexes + }, +]; diff --git a/resources/images/pixabay-1331813-640.png b/resources/images/pixabay-1331813-640.png new file mode 100644 index 0000000..fdad435 Binary files /dev/null and b/resources/images/pixabay-1331813-640.png differ diff --git a/resources/images/pixabay-294469-640.png b/resources/images/pixabay-294469-640.png new file mode 100644 index 0000000..884f82e Binary files /dev/null and b/resources/images/pixabay-294469-640.png differ diff --git a/resources/images/pixabay-30828-640.png b/resources/images/pixabay-30828-640.png new file mode 100644 index 0000000..4288548 Binary files /dev/null and b/resources/images/pixabay-30828-640.png differ diff --git a/resources/images/pixabay-30837-640.png b/resources/images/pixabay-30837-640.png new file mode 100644 index 0000000..1e1867d Binary files /dev/null and b/resources/images/pixabay-30837-640.png differ diff --git a/resources/images/pixabay-33712-640.png b/resources/images/pixabay-33712-640.png new file mode 100644 index 0000000..a4fe284 Binary files /dev/null and b/resources/images/pixabay-33712-640.png differ diff --git a/resources/images/pixabay-36828-640.png b/resources/images/pixabay-36828-640.png new file mode 100644 index 0000000..afa0c75 Binary files /dev/null and b/resources/images/pixabay-36828-640.png differ diff --git a/resources/less/admin.less b/resources/less/admin.less new file mode 100644 index 0000000..5c6ca77 --- /dev/null +++ b/resources/less/admin.less @@ -0,0 +1,29 @@ +.CTFFormControl--range { + display: inline-block; + width: 7em; + margin-right: 10px; +} + +.xxb-catch-the-fish-Page h3 { + max-width: 460px; + margin: 0 -30px; + padding: 15px 30px; + border-top: 2px dashed var(--control-bg, #e8ecf3); + +} + +.xxb-catch-the-fish-Page .helpText { + max-width: 500px; +} + +.CTFTagsTable { + min-width: 500px; + + td, th { + padding: 3px 5px; + } + + th { + text-align: left; + } +} \ No newline at end of file diff --git a/resources/less/forum.less b/resources/less/forum.less new file mode 100644 index 0000000..e52b584 --- /dev/null +++ b/resources/less/forum.less @@ -0,0 +1,255 @@ +.catchthefish-table { + width: 100%; + border-collapse: collapse; + + th, td { + padding: 5px 10px; + text-align: left; + border-bottom: 1px solid var(--control-bg, #eee); + } + + tr:nth-child(odd) td { + background: var(--control-bg, #fafafa); + } + + img { + max-height: 40px; + } + + .Avatar { + width: 24px; + height: 24px; + font-size: 12px; + line-height: 24px; + } +} + +.catchthefish-basket { + position: fixed; + z-index: 1000; + bottom: 16px; + right: 16px; + width: 220px; + background: var(--body-bg); + border: 1px solid var(--control-bg, #ddd); + border-radius: 12px; + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.18); + padding: 12px; + text-align: center; +} + +.catchthefish-basket-title { + font-weight: bold; +} + +.catchthefish-basket-fish { + cursor: move; + max-width: 150px; + margin: 8px auto; + padding: 6px; + border: 2px solid transparent; + border-radius: 10px; + touch-action: manipulation; + + &.active { + border-color: var(--primary-color); + background: var(--control-bg, #f0f0f0); + } +} + +.catchthefish-basket-entry { + user-select: none; +} + +.catchthefish-drop-area { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + border: 4px dashed #dedede; + border-radius: 20px; + background: rgba(255, 255, 255, 0.5); + display: flex; + align-items: center; + justify-content: center; + color: #dedede; + font-weight: bold; + font-size: 2em; + z-index: 5; +} + +.catchthefish-moving-fish { + position: absolute; + top: 10px; + left: 50%; + width: 140px; + opacity: 0.9; + animation: 14s swim linear infinite; + cursor: pointer; + + &.catchthefish-animate-flip .catchthefish-image { + animation: 14s flip linear infinite; + } + + &.catchthefish-animate-reverse { + animation-name: swim-reverse; + + &.catchthefish-animate-flip .catchthefish-image { + animation-name: flip-reverse; + } + } +} + +.UserCard, +.DiscussionHero { + position: relative; +} + +.catchthefish-name { + text-align: center; + font-weight: bold; +} + +.catchthefish-image { + max-width: 100%; +} + +@media (max-width: 767px) { + .catchthefish-basket { + left: 8px; + right: 8px; + bottom: 8px; + width: auto; + max-height: 42vh; + overflow-y: auto; + padding: 10px; + } + + .catchthefish-basket p { + margin: 4px 0 8px; + font-size: 12px; + } + + .catchthefish-basket-entry { + display: inline-block; + width: 92px; + vertical-align: top; + margin: 0 4px; + } + + .catchthefish-basket-fish { + max-width: 76px; + cursor: pointer; + } + + .catchthefish-basket-time { + font-size: 11px; + line-height: 1.3; + } + + .catchthefish-drop-area { + font-size: 1.1em; + border-width: 3px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.75); + } +} + +.catchthefish-no-image { + background: black; + color: white; + display: flex; + align-items: center; + justify-content: center; + padding: 5px; +} + +.catchthefish-catch-modal { + text-align: center; + + .catchthefish-image { + display: block; + width: 80%; + margin: 0 auto 20px; + } + + .Avatar { + width: 24px; + height: 24px; + font-size: 12px; + line-height: 24px; + } +} + +@keyframes swim { + 0% { + transform: translateX(-200px); + } + 48% { + transform: translateX(200px); + } + 50% { + transform: translateX(200px); + } + 98% { + transform: translateX(-200px); + } + 100% { + transform: translateX(-200px); + } +} + +@keyframes swim-reverse { + 0% { + transform: translateX(200px); + } + 48% { + transform: translateX(-200px); + } + 50% { + transform: translateX(-200px); + } + 98% { + transform: translateX(200px); + } + 100% { + transform: translateX(200px); + } +} + +@keyframes flip { + 0% { + transform: scaleX(-1); + } + 48% { + transform: scaleX(-1); + } + 50% { + transform: scaleX(1); + } + 98% { + transform: scaleX(1); + } + 100% { + transform: scaleX(-1); + } +} + +@keyframes flip-reverse { + 0% { + transform: scaleX(1); + } + 48% { + transform: scaleX(1); + } + 50% { + transform: scaleX(-1); + } + 98% { + transform: scaleX(-1); + } + 100% { + transform: scaleX(1); + } +} diff --git a/resources/locale/en.yml b/resources/locale/en.yml new file mode 100644 index 0000000..67f581f --- /dev/null +++ b/resources/locale/en.yml @@ -0,0 +1,183 @@ +xxb-catch-the-fish: + forum: + moderate: + add: Add + edit: Edit + delete: Delete + fishes: Fishes + rounds: Rounds + + nav: + rankings: Catch The Fish Rankings + settings: Catch The Fish Settings + + new-round-modal: + title: New Round + + new-fish-modal: + title: New Fish + + edit-round-modal: + title: Edit Round "{name}" + + edit-fish-modal: + title: Edit Fish "{name}" + + caught-fish-modal: + title: You caught a fish ! + named-by: Named by + placed-by: Placed by + congratulation: Congratulation, you caught a fish ! You have caught {catch_count} fishes. + name: Fish name + name-help: You can rename the fish if you want. The name must respect the forum guidelines. + placement-help: You can hide the fish where you want on the forum in recent discussions and active user profiles. You cannot catch a fish you placed yourself. + submit-name-place-later: Save name and choose hiding place + submit-name: Save name + submit-place-later: Yep I'll choose a hiding place + submit-continue: Continue + submit-name-place-random: Save name and let the fish hide itself + submit-place-random: Let the fish hide itself + + edit-round: + name: Name + name-help: The public name for this round, will be shown in banner and in ranking + starts-at: Start time + ends-at: End time + starting-pack: Starting Pack + starting-pack-help: Will include a few fishes to get you started in no time + create: Create round + save: Save round + delete: Delete round + delete-confirmation: Delete round "{name}" ? + + edit-fish: + name: Name + name-help: The current name of the fish. Can be edited by players if they have the permission to rename fishes + create: Create fish + save: Save fish + delete: Delete fish + delete-confirmation: Delete fish "{name}" ? + + moving-fish: + login: Login to catch fishes + + fish-image: + alt: Image of fish "{name}" + missing: Missing fish image + + round-alert: + message: Round "{name}", catch as many fishes as you can until {time} + rankings: See rankings + + basket: + title: Fish Basket + drag-help: Drag and drop the fish on a discussion, post or user profile + time: "Place until: {time}" + + table-round: + loading: Loading... + title: Rounds + new: New round + name: Name + start: From + end: Until + actions: Actions + edit: Edit + fishes: Fishes + + table-fish: + loading: Loading... + title: Fishes for round {name} + new: New fish + image: Image + name: Name + user-name: Last named by + user-place: Last placed by + placement: Current location + actions: Actions + edit: Edit + no-user-name: Not renamed + no-user-place: Random + upload: Upload new image + new-from-image: Create new fishes via bulk image import + + table-ranking: + loading: Loading... + title: Ranking of round {name} + rank: Name + count: Fishes caught + user: User + + page-ranking: + title: Rankings + permission: You do not have permission to see the rankings + nothing: No active rounds + + drop-area: + message: You can drop the fish here + missing-from-store: Could not find the fish in the data store + + admin: + settings: + user: Users + user-age: How long since a user must have been active for a fish to be placed on it (days) + user-probability: Probability to be placed on a user profile (%) + discussion: Discussions + discussion-age: How old discussions can be for a fish to be placed on it (days) + discussion-tags: Tag whitelist + discussion-tags-unavailable: Setting not available because the Tags extension is disabled. + discussion-tags-help: > + If you don't select any tag, any discussion that matches the time settings above can be chosen. + If any number of tags are selected, a discussion needs to have at least one of the selected tags to be chosen. + The probability is the chance to be chosen after all previous choices have been tried. + The last tag in the list will always act as fallback, so make sure that tag has at least one valid discussion inside. + tags-header: + tag: Tag + probability: Probability + tags-control: + choose: Choose a tag + add: Add to whitelist + down: Move down + up: Move up + delete: Remove from whitelist + tags-fallback: + last-line: Fallback + single-line: At least 2 tags required for probability + post: Posts + post-age: How old posts can be for a fish to be placed on it (days) + post-probability: Probability to be placed on a post rather than the discussion (%) + general: General + time-to-place: Time allowed to place fish before it is randomly placed (minutes) + alert-round: Show an alert with the round name and duration on the homepage + animate-flip: Flip fish images based on direction + + permissions: + visible: See the fishes + list-rankings: Access the rankings page + participate: Participate in the rounds and catch fishes + choose-place: Choose fish placement after catching + choose-name: Choose fish name after catching + moderate: Moderate rounds and fishes + + api: + default-fish-name: "Fish #{number}" + wrong-catch-placement: There is no fish here. Maybe you weren't quick enough and someone else got it first + too-many-placement-models: Only one placement is possible at a time + invalid-discussion-id: Discussion doesn't seem to exist, maybe it was recently deleted + invalid-post-id: Post doesn't seem to exist, maybe it was recently deleted + invalid-user-id: User doesn't seem to exist, maybe it was recently deleted + model-deleted: Can't place fish on a deleted discussion or post + model-private: Can't place fish on a private discussion or post + tag-not-allowed: You can't place fish under this tag + inactive-discussion: Can't place fish on discussion inactive for more than {days} days + inactive-post: Can't place fish on post created more than {days} days ago + inactive-user: Can't place fish on user inactive for more than {days} days + non-comment-post: Can only place fishes on comment posts + user-suspended: Can't place fish on a suspended user + random-error: Could not generate a random fish placement + cannot-catch-own-fish: You cannot catch a fish you placed yourself + cannot-catch-hold-fish: The previous catcher needs to release the fish before you can catch it + fish-update-wrong-user: You can no longer edit this fish. Somebody else has probably caught it + fish-update-expired: Too late, you can no longer edit this fish. It has automatically been placed somewhere in the meantime + round-not-started: This round has not started + round-finished: This round is finished diff --git a/resources/locale/zh.yml b/resources/locale/zh.yml new file mode 100644 index 0000000..65f623d --- /dev/null +++ b/resources/locale/zh.yml @@ -0,0 +1,183 @@ +xxb-catch-the-fish: + forum: + moderate: + add: 添加 + edit: 编辑 + delete: 删除 + fishes: 鱼 + rounds: 轮次 + + nav: + rankings: 捕鱼排行榜 + settings: 捕鱼设置 + + new-round-modal: + title: 新建轮次 + + new-fish-modal: + title: 新建鱼 + + edit-round-modal: + title: 编辑轮次「{name}」 + + edit-fish-modal: + title: 编辑鱼「{name}」 + + caught-fish-modal: + title: 你抓到了一条鱼! + named-by: 命名者 + placed-by: 放置者 + congratulation: 恭喜,你抓到了一条鱼!你已经抓到了 {catch_count} 条鱼。 + name: 鱼的名称 + name-help: 如果你愿意,可以重新命名这条鱼。但请注意名称必须遵守规范。 + placement-help: 你可以把鱼藏在论坛近期的讨论或活跃用户资料页中的任意位置。但是你不能抓自己放置的鱼。 + submit-name-place-later: 保存名称并选择藏鱼位置 + submit-name: 保存名称 + submit-place-later: 好的,我稍后选择藏鱼位置 + submit-continue: 继续 + submit-name-place-random: 保存名称并让鱼自己藏起来 + submit-place-random: 让鱼自己藏起来 + + edit-round: + name: 名称 + name-help: 此轮次的公开名称,会显示在横幅和排行榜中。 + starts-at: 开始时间 + ends-at: 结束时间 + starting-pack: 初始鱼包 + starting-pack-help: 会包含几条鱼,方便你快速开始。。 + create: 创建轮次 + save: 保存轮次 + delete: 删除轮次 + delete-confirmation: 确定要删除轮次「{name}」吗? + + edit-fish: + name: 名称 + name-help: 当前鱼的名称。如果用户拥有重命名权限,也可以编辑它。 + create: 创建鱼 + save: 保存鱼 + delete: 删除鱼 + delete-confirmation: 确定要删除鱼「{name}」吗? + + moving-fish: + login: 登录后即可抓鱼 + + fish-image: + alt: 鱼「{name}」的图片 + missing: 缺少鱼的图片 + + round-alert: + message: 轮次「{name}」正在进行,请在 {time} 到前尽可能的多抓鱼。 + rankings: 查看排行榜 + + basket: + title: 鱼篓 + drag-help: 将鱼拖放到讨论、帖子或用户资料页上。 + time: "放置截止时间:{time}" + + table-round: + loading: 正在加载... + title: 轮次 + new: 新建轮次 + name: 名称 + start: 开始 + end: 结束 + actions: 操作 + edit: 编辑 + fishes: 鱼 + + table-fish: + loading: 正在加载... + title: 轮次 {name} 中的鱼 + new: 新建鱼 + image: 图片 + name: 名称 + user-name: 最后命名者 + user-place: 最后放置者 + placement: 当前位置 + actions: 操作 + edit: 编辑 + no-user-name: 未重命名 + no-user-place: 随机 + upload: 上传新图片 + new-from-image: 通过批量图片导入创建新鱼 + + table-ranking: + loading: 正在加载... + title: 轮次 {name} 的排行榜 + rank: 名次 + count: 抓到的鱼数 + user: 用户 + + page-ranking: + title: 排行榜 + permission: 你没有权限查看排行榜 + nothing: 当前没有进行中的轮次 + + drop-area: + message: 你可以把鱼放在这里 + missing-from-store: 无法在数据存储中找到这条鱼 + + admin: + settings: + user: 用户 + user-age: 用户最近活跃时间在多少天内,鱼才可以被放置到其资料页上 + user-probability: 放置到用户资料页的概率(%) + discussion: 讨论 + discussion-age: 讨论创建后多少天内,鱼才可以被放置到其中 + discussion-tags: 标签白名单 + discussion-tags-unavailable: 标签扩展已禁用或未安装,因此此设置不可用。 + discussion-tags-help: > + 如果不选择任何标签,任何符合上方时间设置的讨论都可能被选中。 + 如果选择了一个或多个标签,讨论至少需要包含其中一个被选中的标签才可能被选中。 + 概率表示在前面的选择全部尝试之后,该标签被选中的机会。 + 列表中的最后一个标签会始终作为兜底选项,因此请确保该标签下至少有一个有效讨论。 + tags-header: + tag: 标签 + probability: 概率 + tags-control: + choose: 选择标签 + add: 添加到白名单 + down: 下移 + up: 上移 + delete: 从白名单移除 + tags-fallback: + last-line: 兜底 + single-line: 至少需要 2 个标签才能设置概率 + post: 帖子 + post-age: 帖子创建后多少天内,鱼才可以被放置到其中 + post-probability: 相比讨论,放置到帖子上的概率(%) + general: 通用 + time-to-place: 抓到鱼后允许手动放置的时间(分钟) + alert-round: 在首页显示包含轮次名称和持续时间的提示 + animate-flip: 根据移动方向翻转鱼的图片 + + permissions: + visible: 查看鱼 + list-rankings: 访问排行榜页面 + participate: 参与轮次并抓鱼 + choose-place: 抓到鱼后选择放置位置 + choose-name: 抓到鱼后选择鱼的名称 + moderate: 管理轮次和鱼 + + api: + default-fish-name: "鱼 #{number}" + wrong-catch-placement: 这里没有鱼。也许你慢了一步,已经被别人先抓走了。 + too-many-placement-models: 一次只能选择一个放置位置。 + invalid-discussion-id: 讨论似乎不存在,可能最近已被删除。 + invalid-post-id: 帖子似乎不存在,可能最近已被删除。 + invalid-user-id: 用户似乎不存在,可能最近已被删除。 + model-deleted: 不能把鱼放在已删除的讨论或帖子上。 + model-private: 不能把鱼放在私密讨论或帖子上。 + tag-not-allowed: 你不能把鱼放在这个标签下。 + inactive-discussion: 不能把鱼放在超过 {days} 天未活跃的讨论中。 + inactive-post: 不能把鱼放在创建超过 {days} 天的帖子上。 + inactive-user: 不能把鱼放在超过 {days} 天未活跃的用户资料页上。 + non-comment-post: 只能把鱼放在评论帖子上。 + user-suspended: 不能把鱼放在被封禁的用户资料页上。 + random-error: 无法随机生成鱼的放置位置。 + cannot-catch-own-fish: 你不能抓自己放置的鱼。 + cannot-catch-hold-fish: 上一个抓到鱼的人需要先放开这条鱼,你才能抓它。 + fish-update-wrong-user: 你不能再编辑这条鱼了。可能已经被其他人抓走了。 + fish-update-expired: 太晚了,你不能再编辑这条鱼了。它已经自动被放置到其他地方了。 + round-not-started: 此轮次尚未开始。 + round-finished: 此轮次已经结束。 diff --git a/src/Access/FishPolicy.php b/src/Access/FishPolicy.php new file mode 100644 index 0000000..9bd83c4 --- /dev/null +++ b/src/Access/FishPolicy.php @@ -0,0 +1,83 @@ +isAdmin() || $actor->can('catchthefish.moderate'); + } + + public function update(User $actor, Fish $fish) + { + return $this->create($actor); + } + + public function delete(User $actor, Fish $fish) + { + return $this->update($actor, $fish); + } + + public function see(User $actor, Fish $fish) + { + return $actor->isAdmin() || $actor->can('catchthefish.visible'); + } + + public function catch(User $actor, Fish $fish) + { + if ($actor->isAdmin()) { + return true; + } + + if ($actor->id === $fish->user_id_last_placement) { + throw new ValidationException([ + 'placement' => resolve(Translator::class)->trans(self::TRANSLATION_PREFIX . 'cannot-catch-own-fish'), + ]); + } + + if (!$fish->placement_valid_since || $fish->placement_valid_since->gt(Carbon::now())) { + throw new ValidationException([ + 'placement' => resolve(Translator::class)->trans(self::TRANSLATION_PREFIX . 'cannot-catch-hold-fish'), + ]); + } + + return $actor->can('participate', $fish->round); + } + + protected function updateCatched(User $actor, Fish $fish) + { + if (!$fish->user_id_last_catch || $fish->user_id_last_catch !== $actor->id) { + throw new ValidationException([ + 'placement' => resolve(Translator::class)->trans(self::TRANSLATION_PREFIX . 'fish-update-wrong-user'), + ]); + } + + if (!$fish->placement_valid_since || $fish->placement_valid_since->lt(Carbon::now())) { + throw new ValidationException([ + 'placement' => resolve(Translator::class)->trans(self::TRANSLATION_PREFIX . 'fish-update-expired'), + ]); + } + + return true; + } + + public function name(User $actor, Fish $fish) + { + return $actor->isAdmin() || ($this->updateCatched($actor, $fish) && $actor->can('catchthefish.choose-name')); + } + + public function place(User $actor, Fish $fish) + { + return $actor->isAdmin() || ($this->updateCatched($actor, $fish) && $actor->can('catchthefish.choose-place')); + } +} diff --git a/src/Access/RoundPolicy.php b/src/Access/RoundPolicy.php new file mode 100644 index 0000000..eb2a710 --- /dev/null +++ b/src/Access/RoundPolicy.php @@ -0,0 +1,73 @@ +isAdmin() || $actor->can('catchthefish.moderate'); + } + + public function create(User $actor) + { + return $this->list($actor); + } + + public function createFish(User $actor, Round $round) + { + return $this->create($actor); + } + + public function update(User $actor, Round $round) + { + return $this->create($actor); + } + + public function delete(User $actor, Round $round) + { + return $this->update($actor, $round); + } + + public function participate(User $actor, Round $round) + { + if ($actor->isAdmin()) { + return true; + } + + $now = Carbon::now(); + + if ($round->starts_at && $round->starts_at->gt($now)) { + throw new ValidationException([ + 'placement' => resolve(Translator::class)->trans(self::TRANSLATION_PREFIX . 'round-not-started'), + ]); + } + + if ($round->ends_at && $round->ends_at->lt($now)) { + throw new ValidationException([ + 'placement' => resolve(Translator::class)->trans(self::TRANSLATION_PREFIX . 'round-finished'), + ]); + } + + return $actor->can('catchthefish.participate'); + } + + public function listFishes(User $actor, Round $round) + { + return $this->list($actor); + } + + public function listRankings(User $actor, Round $round) + { + return $actor->isAdmin() || $actor->can('catchthefish.list-rankings'); + } +} diff --git a/src/Api/Resource/FishResource.php b/src/Api/Resource/FishResource.php new file mode 100644 index 0000000..5482e45 --- /dev/null +++ b/src/Api/Resource/FishResource.php @@ -0,0 +1,79 @@ +get(fn(Fish $fish, Context $context) => $fish->image_url), + Schema\Arr::make('placement') + ->get(function (Fish $fish, Context $context) { + if (!$this->actorCan($context, 'catch', $fish) && !$this->actorCan($context, 'catchthefish.moderate')) { + return null; + } + + return [ + 'discussion_id' => $fish->discussion_id_placement, + 'post_id' => $fish->post_id_placement, + 'user_id' => $fish->user_id_placement, + ]; + }), + Schema\Boolean::make('canSee') + ->get(fn(Fish $fish, Context $context) => $this->actorCan($context, 'see', $fish)), + Schema\Boolean::make('canCatch') + ->get(fn(Fish $fish, Context $context) => $this->actorCan($context, 'catch', $fish)), + Schema\Boolean::make('canName') + ->get(fn(Fish $fish, Context $context) => $this->actorCan($context, 'name', $fish)), + Schema\Boolean::make('canPlace') + ->get(fn(Fish $fish, Context $context) => $this->actorCan($context, 'place', $fish)), + Schema\Str::make('placeUntil') + ->get(fn(Fish $fish, Context $context) => $this->actorCan($context, 'place', $fish) ? $this->formatDate($fish->placement_valid_since) : null), + ]; + } + + protected function actorCan(Context $context, string $ability, $arguments = []): bool + { + $actor = $context->getActor(); + + if ($actor->isAdmin()) { + return true; + } + + try { + return $actor->can($ability, $arguments); + } catch (ValidationException $exception) { + return false; + } + } + + protected function formatDate($date): ?string + { + return $date instanceof DateTimeInterface ? $date->format(DateTimeInterface::ATOM) : null; + } +} diff --git a/src/Api/Resource/RoundResource.php b/src/Api/Resource/RoundResource.php new file mode 100644 index 0000000..0a9d92b --- /dev/null +++ b/src/Api/Resource/RoundResource.php @@ -0,0 +1,50 @@ +get(fn(Round $round, Context $context) => $this->formatDate($round->starts_at)), + Schema\Str::make('ends_at') + ->get(fn(Round $round, Context $context) => $this->formatDate($round->ends_at)), + Schema\Integer::make('my_catch_count') + ->get(function (Round $round, Context $context) { + $ranking = $round->userRanking($context->getActor()); + + return $ranking ? $ranking->catch_count : 0; + }), + ]; + } + + protected function formatDate($date): ?string + { + return $date instanceof DateTimeInterface ? $date->format(DateTimeInterface::ATOM) : null; + } +} diff --git a/src/ConfigureBasketRelationship.php b/src/ConfigureBasketRelationship.php new file mode 100644 index 0000000..d28dd13 --- /dev/null +++ b/src/ConfigureBasketRelationship.php @@ -0,0 +1,20 @@ +hasMany(Fish::class, 'user_id_last_catch') + ->whereHas('round', function (Builder $query) { + $query->activeRound(); + }) + ->where('placement_valid_since', '>', Carbon::now()) + ->orderBy('name'); + } +} diff --git a/src/ConfigureFishesRelationship.php b/src/ConfigureFishesRelationship.php new file mode 100644 index 0000000..2cc3911 --- /dev/null +++ b/src/ConfigureFishesRelationship.php @@ -0,0 +1,25 @@ +foreignKey = $foreignKey; + } + + public function __invoke(AbstractModel $model) + { + return $model->hasMany(Fish::class, $this->foreignKey) + ->whereHas('round', function (Builder $query) { + $query->activeRound(); + }) + ->activeFish(); + } +} diff --git a/src/Controllers/AbstractCreateController.php b/src/Controllers/AbstractCreateController.php new file mode 100644 index 0000000..555e6c3 --- /dev/null +++ b/src/Controllers/AbstractCreateController.php @@ -0,0 +1,7 @@ +delete($request); + + return new EmptyResponse(204); + } + + abstract protected function delete(ServerRequestInterface $request); +} diff --git a/src/Controllers/AbstractListController.php b/src/Controllers/AbstractListController.php new file mode 100644 index 0000000..4fd50e1 --- /dev/null +++ b/src/Controllers/AbstractListController.php @@ -0,0 +1,14 @@ +data($request, $document); + $serializer = resolve($this->serializer); + + if (method_exists($serializer, 'setRequest')) { + $serializer->setRequest($request); + } + + $element = $this->createElement($data, $serializer) + ->with($this->extractInclude($request)) + ->fields($this->extractFields($request)); + + $document->setData($element); + + return new JsonResponse($document); + } + + abstract protected function data(ServerRequestInterface $request, Document $document); + + abstract protected function createElement($data, SerializerInterface $serializer); + + protected function extractInclude(ServerRequestInterface $request) + { + $available = array_merge($this->include, $this->optionalInclude); + + return $this->buildParameters($request)->getInclude($available) ?: $this->include; + } + + protected function extractFields(ServerRequestInterface $request) + { + return $this->buildParameters($request)->getFields(); + } + + protected function extractOffset(ServerRequestInterface $request) + { + return (int) $this->buildParameters($request)->getOffset($this->extractLimit($request)) ?: 0; + } + + protected function extractLimit(ServerRequestInterface $request) + { + return (int) $this->buildParameters($request)->getLimit($this->maxLimit) ?: $this->limit; + } + + protected function buildParameters(ServerRequestInterface $request): Parameters + { + return new Parameters($request->getQueryParams()); + } + + protected function input(ServerRequestInterface $request, string $key, $default = null) + { + return Arr::get($request->getParsedBody(), $key, $default); + } +} diff --git a/src/Controllers/AbstractShowController.php b/src/Controllers/AbstractShowController.php new file mode 100644 index 0000000..c4e9793 --- /dev/null +++ b/src/Controllers/AbstractShowController.php @@ -0,0 +1,14 @@ +fishes = $fishes; + $this->placement = $placement; + } + + protected function data(ServerRequestInterface $request, Document $document) + { + $id = Arr::get($request->getQueryParams(), 'id'); + + $fish = $this->fishes->findOrFail($id); + + return $this->placement->catch(RequestUtil::getActor($request), $fish, $request->getParsedBody()); + } +} diff --git a/src/Controllers/FishDeleteController.php b/src/Controllers/FishDeleteController.php new file mode 100644 index 0000000..784b847 --- /dev/null +++ b/src/Controllers/FishDeleteController.php @@ -0,0 +1,30 @@ +fishes = $fishes; + } + + protected function delete(ServerRequestInterface $request) + { + $id = Arr::get($request->getQueryParams(), 'id'); + + $fish = $this->fishes->findOrFail($id); + + RequestUtil::getActor($request)->assertCan('delete', $fish); + + $this->fishes->delete($fish); + } +} diff --git a/src/Controllers/FishImageBulkController.php b/src/Controllers/FishImageBulkController.php new file mode 100644 index 0000000..098ed90 --- /dev/null +++ b/src/Controllers/FishImageBulkController.php @@ -0,0 +1,42 @@ +rounds = $rounds; + $this->fishes = $fishes; + } + + protected function data(ServerRequestInterface $request, Document $document) + { + $roundId = Arr::get($request->getQueryParams(), 'id'); + + $round = $this->rounds->findOrFail($roundId); + + RequestUtil::getActor($request)->assertCan('createFish', $round); + + $files = array_filter($request->getUploadedFiles(), function (string $key) { + return Str::startsWith($key, 'image'); + }, ARRAY_FILTER_USE_KEY); + + return $this->fishes->bulkImageImport($round, $files); + } +} diff --git a/src/Controllers/FishImageController.php b/src/Controllers/FishImageController.php new file mode 100644 index 0000000..3bfea81 --- /dev/null +++ b/src/Controllers/FishImageController.php @@ -0,0 +1,43 @@ +fishes = $fishes; + } + + protected function data(ServerRequestInterface $request, Document $document) + { + $id = Arr::get($request->getQueryParams(), 'id'); + + $fish = $this->fishes->findOrFail($id); + + RequestUtil::getActor($request)->assertCan('update', $fish); + + $file = Arr::get($request->getUploadedFiles(), 'image'); + + if (!$file) { + throw new ValidationException([ + 'image' => 'No image file uploaded', + ]); + } + + return $this->fishes->updateImage($fish, $file); + } +} diff --git a/src/Controllers/FishIndexController.php b/src/Controllers/FishIndexController.php new file mode 100644 index 0000000..ec36d83 --- /dev/null +++ b/src/Controllers/FishIndexController.php @@ -0,0 +1,44 @@ +rounds = $rounds; + $this->fishes = $fishes; + } + + protected function data(ServerRequestInterface $request, Document $document) + { + $roundId = Arr::get($request->getQueryParams(), 'id'); + + $round = $this->rounds->findOrFail($roundId); + + RequestUtil::getActor($request)->assertCan('listFishes', $round); + + return $this->fishes->all($round, $this->extractLimit($request), $this->extractOffset($request)); + } +} diff --git a/src/Controllers/FishPlaceController.php b/src/Controllers/FishPlaceController.php new file mode 100644 index 0000000..8debe8d --- /dev/null +++ b/src/Controllers/FishPlaceController.php @@ -0,0 +1,35 @@ +fishes = $fishes; + $this->placement = $placement; + } + + protected function data(ServerRequestInterface $request, Document $document) + { + $id = Arr::get($request->getQueryParams(), 'id'); + + $fish = $this->fishes->findOrFail($id); + + return $this->placement->place(RequestUtil::getActor($request), $fish, $request->getParsedBody()); + } +} diff --git a/src/Controllers/FishStoreController.php b/src/Controllers/FishStoreController.php new file mode 100644 index 0000000..d15f9c4 --- /dev/null +++ b/src/Controllers/FishStoreController.php @@ -0,0 +1,39 @@ +rounds = $rounds; + $this->fishes = $fishes; + } + + protected function data(ServerRequestInterface $request, Document $document) + { + $roundId = Arr::get($request->getQueryParams(), 'id'); + + $round = $this->rounds->findOrFail($roundId); + + RequestUtil::getActor($request)->assertCan('createFish', $round); + + $attributes = Arr::get($request->getParsedBody(), 'data.attributes', []); + + return $this->fishes->store($round, $attributes); + } +} diff --git a/src/Controllers/FishUpdateController.php b/src/Controllers/FishUpdateController.php new file mode 100644 index 0000000..60ecbc4 --- /dev/null +++ b/src/Controllers/FishUpdateController.php @@ -0,0 +1,36 @@ +fishes = $fishes; + } + + protected function data(ServerRequestInterface $request, Document $document) + { + $id = Arr::get($request->getQueryParams(), 'id'); + + $fish = $this->fishes->findOrFail($id); + + RequestUtil::getActor($request)->assertCan('update', $fish); + + $attributes = Arr::get($request->getParsedBody(), 'data.attributes', []); + + return $this->fishes->update($fish, $attributes); + } +} diff --git a/src/Controllers/RankingIndexController.php b/src/Controllers/RankingIndexController.php new file mode 100644 index 0000000..7420935 --- /dev/null +++ b/src/Controllers/RankingIndexController.php @@ -0,0 +1,41 @@ +rounds = $rounds; + $this->rankings = $rankings; + } + + protected function data(ServerRequestInterface $request, Document $document) + { + $roundId = Arr::get($request->getQueryParams(), 'id'); + + $round = $this->rounds->findOrFail($roundId); + + RequestUtil::getActor($request)->assertCan('listRankings', $round); + + return $this->rankings->all($round); + } +} diff --git a/src/Controllers/RoundDeleteController.php b/src/Controllers/RoundDeleteController.php new file mode 100644 index 0000000..5d21fa8 --- /dev/null +++ b/src/Controllers/RoundDeleteController.php @@ -0,0 +1,30 @@ +rounds = $rounds; + } + + protected function delete(ServerRequestInterface $request) + { + $id = Arr::get($request->getQueryParams(), 'id'); + + $round = $this->rounds->findOrFail($id); + + RequestUtil::getActor($request)->assertCan('delete', $round); + + $this->rounds->delete($round); + } +} diff --git a/src/Controllers/RoundIndexController.php b/src/Controllers/RoundIndexController.php new file mode 100644 index 0000000..4202fcd --- /dev/null +++ b/src/Controllers/RoundIndexController.php @@ -0,0 +1,38 @@ +rounds = $rounds; + } + + protected function data(ServerRequestInterface $request, Document $document) + { + $actor = RequestUtil::getActor($request); + + if (!$actor->can('list', Round::class)) { + $actor->assertCan('catchthefish.visible'); + + return $this->rounds->allActive(); + } + + $actor->assertCan('list', Round::class); + + return $this->rounds->all($this->extractLimit($request), $this->extractOffset($request)); + } +} diff --git a/src/Controllers/RoundShowController.php b/src/Controllers/RoundShowController.php new file mode 100644 index 0000000..594a483 --- /dev/null +++ b/src/Controllers/RoundShowController.php @@ -0,0 +1,34 @@ +rounds = $rounds; + } + + protected function data(ServerRequestInterface $request, Document $document) + { + $id = Arr::get($request->getQueryParams(), 'id'); + + $round = $this->rounds->findOrFail($id); + + RequestUtil::getActor($request)->assertCan('view', $round); + + return $round; + } +} diff --git a/src/Controllers/RoundStoreController.php b/src/Controllers/RoundStoreController.php new file mode 100644 index 0000000..6011120 --- /dev/null +++ b/src/Controllers/RoundStoreController.php @@ -0,0 +1,33 @@ +rounds = $rounds; + } + + protected function data(ServerRequestInterface $request, Document $document) + { + RequestUtil::getActor($request)->assertCan('create', Round::class); + + $attributes = Arr::get($request->getParsedBody(), 'data.attributes', []); + + return $this->rounds->store($attributes); + } +} diff --git a/src/Controllers/RoundUpdateController.php b/src/Controllers/RoundUpdateController.php new file mode 100644 index 0000000..af02f29 --- /dev/null +++ b/src/Controllers/RoundUpdateController.php @@ -0,0 +1,36 @@ +rounds = $rounds; + } + + protected function data(ServerRequestInterface $request, Document $document) + { + $id = Arr::get($request->getQueryParams(), 'id'); + + $round = $this->rounds->findOrFail($id); + + RequestUtil::getActor($request)->assertCan('update', $round); + + $attributes = Arr::get($request->getParsedBody(), 'data.attributes', []); + + return $this->rounds->update($round, $attributes); + } +} diff --git a/src/Extenders/ApiControllerIncludes.php b/src/Extenders/ApiControllerIncludes.php new file mode 100644 index 0000000..1a39aff --- /dev/null +++ b/src/Extenders/ApiControllerIncludes.php @@ -0,0 +1,42 @@ +controllerClasses = $controllerClasses; + } + + public function addInclude($name): self + { + $this->addIncludes[] = $name; + + return $this; + } + + public function extend(Container $container, Extension $extension = null) + { + foreach ($this->controllerClasses as $controllerClass) { + $extender = new ApiController($controllerClass); + + foreach ($this->addIncludes as $addInclude) { + $extender->addInclude($addInclude); + } + + $extender->extend($container, $extension); + } + } +} diff --git a/src/Fish.php b/src/Fish.php new file mode 100644 index 0000000..9fc5c44 --- /dev/null +++ b/src/Fish.php @@ -0,0 +1,83 @@ + 'datetime', + 'last_caught_at' => 'datetime', + ]; + + protected $fillable = [ + 'name', + ]; + + public $timestamps = true; + + public function round(): BelongsTo + { + return $this->belongsTo(Round::class); + } + + public function placementDiscussion(): BelongsTo + { + return $this->belongsTo(Discussion::class, 'discussion_id_placement'); + } + + public function placementPost(): BelongsTo + { + return $this->belongsTo(Post::class, 'post_id_placement'); + } + + public function placementUser(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id_placement'); + } + + public function lastUserCatch(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id_last_catch'); + } + + public function lastUserPlacement(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id_last_placement'); + } + + public function lastUserNaming(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id_last_naming'); + } + + public function getImageUrlAttribute(): ?string + { + if (!$this->image) { + return null; + } + + if (strpos($this->image, '://') === false) { + $generator = resolve(UrlGenerator::class); + + return $generator->to('forum')->path('assets/catch-the-fish/' . $this->image); + } + + return $this->image; + } + + public function scopeActiveFish(Builder $query): Builder + { + return $query->where('placement_valid_since', '<', Carbon::now()); + } +} diff --git a/src/ForumAttributes.php b/src/ForumAttributes.php new file mode 100644 index 0000000..2d341c9 --- /dev/null +++ b/src/ForumAttributes.php @@ -0,0 +1,25 @@ +settings = $settings; + } + + public function __invoke($serializer): array + { + return [ + 'catchTheFishCanModerate' => $serializer->getActor()->can('catchthefish.moderate'), + 'catchTheFishCanSeeRankingsPage' => $serializer->getActor()->can('catchthefish.list-rankings'), + 'catchTheFishAlertRound' => $this->settings->get('catch-the-fish.alertRound') !== '0', + 'catchTheFishAnimateFlip' => $this->settings->get('catch-the-fish.animateFlip') !== '0', + ]; + } +} diff --git a/src/LoadRoundsRelationship.php b/src/LoadRoundsRelationship.php new file mode 100644 index 0000000..15f9474 --- /dev/null +++ b/src/LoadRoundsRelationship.php @@ -0,0 +1,27 @@ +repository = $repository; + } + + public function __invoke(ShowForumController $controller, &$data, ServerRequestInterface $request) + { + if (RequestUtil::getActor($request)->can('catchthefish.visible')) { + $data['catchTheFishActiveRounds'] = $this->repository->allActive(); + } else { + $data['catchTheFishActiveRounds'] = []; + } + } +} diff --git a/src/Providers/StorageServiceProvider.php b/src/Providers/StorageServiceProvider.php new file mode 100644 index 0000000..7af2a36 --- /dev/null +++ b/src/Providers/StorageServiceProvider.php @@ -0,0 +1,23 @@ +container->bind('catchthefish-assets', function () { + return new Filesystem(new LocalFilesystemAdapter($this->container->make(Paths::class)->public . '/assets/catch-the-fish')); + }); + + $this->container->when(FishImageUploader::class) + ->needs(Filesystem::class) + ->give('catchthefish-assets'); + } +} diff --git a/src/Ranking.php b/src/Ranking.php new file mode 100644 index 0000000..b54e491 --- /dev/null +++ b/src/Ranking.php @@ -0,0 +1,34 @@ + 'int', + ]; + + protected $fillable = [ + 'round_id', + 'user_id', + 'catch_count', + ]; + + public $timestamps = true; + + public function round(): BelongsTo + { + return $this->belongsTo(Round::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/src/Repositories/FishImageUploader.php b/src/Repositories/FishImageUploader.php new file mode 100644 index 0000000..0b2b1c5 --- /dev/null +++ b/src/Repositories/FishImageUploader.php @@ -0,0 +1,59 @@ +assets = $assets; + } + + public function upload(Fish $fish, Image $image) + { + if (extension_loaded('exif')) { + if (method_exists($image, 'orientate')) { + $image->orientate(); + } elseif (method_exists($image, 'orient')) { + $image = $image->orient(); + } + } + + if (method_exists($image, 'scaleDown')) { + $image = $image->scaleDown(width: 300, height: 200); + } else { + $image = $image->resize(300, 200, function (Constraint $size) { + $size->aspectRatio(); + $size->upsize(); + }); + } + + $encodedImage = method_exists($image, 'toPng') ? (string) $image->toPng() : (string) $image->encode('png'); + + $imagePath = Str::random() . '.png'; + + $this->remove($fish); + $fish->image = $imagePath; + + $this->assets->write($imagePath, $encodedImage); + } + + public function remove(Fish $fish) + { + $imagePath = $fish->image; + + if ($imagePath && $this->assets->fileExists($imagePath)) { + $this->assets->delete($imagePath); + } + + $fish->image = null; + } +} diff --git a/src/Repositories/FishRepository.php b/src/Repositories/FishRepository.php new file mode 100644 index 0000000..7bc9344 --- /dev/null +++ b/src/Repositories/FishRepository.php @@ -0,0 +1,202 @@ +paths = $paths; + $this->validator = $validator; + $this->imageValidator = $imageValidator; + $this->uploader = $uploader; + $this->translator = $translator; + } + + public function all(Round $round, $limit = null, $offset = 0) + { + $query = $round->fishes()->getQuery(); + + if ($limit !== null) { + $query->limit($limit)->offset($offset); + } + + return $query->get(); + } + + public function findOrFail($id): Fish + { + return Fish::query()->where('id', $id)->firstOrFail(); + } + + public function store(Round $round, array $attributes): Fish + { + $this->validator->assertValid($attributes); + + $fish = new Fish($attributes); + $fish->round()->associate($round); + Placement::random()->assign($fish); + $fish->placement_valid_since = Carbon::now(); + $fish->save(); + + return $fish; + } + + public function update(Fish $fish, array $attributes): Fish + { + $this->validator->assertValid($attributes); + + if (Arr::has($attributes, 'name')) { + $fish->user_id_last_naming = null; + } + + $fish->fill($attributes); + $fish->save(); + + return $fish; + } + + public function updateImage(Fish $fish, UploadedFileInterface $file): Fish + { + $tmpFile = tempnam($this->paths->storage . '/tmp', 'catch-the-fish'); + $file->moveTo($tmpFile); + + try { + $file = new UploadedFile( + $tmpFile, + $file->getClientFilename(), + $file->getClientMediaType(), + $file->getError(), + true + ); + + $this->imageValidator->assertValid(['image' => $file]); + + $image = $this->readImage($tmpFile); + + $this->uploader->upload($fish, $image); + + $fish->save(); + } finally { + @unlink($tmpFile); + } + + return $fish; + } + + public function bulkImageImport(Round $round, array $files): array + { + $filesToUnlink = []; + $originalNames = []; + + try { + return collect($files)->map(function (UploadedFileInterface $file, $index) use (&$filesToUnlink, &$originalNames) { + $tmpFile = tempnam($this->paths->storage . '/tmp', 'catch-the-fish'); + $file->moveTo($tmpFile); + $filesToUnlink[] = $tmpFile; + + $originalNames[$index] = $file->getClientFilename(); + + $file = new UploadedFile( + $tmpFile, + $file->getClientFilename(), + $file->getClientMediaType(), + $file->getError(), + true + ); + + $this->imageValidator->assertValid(['image' => $file]); + + return $this->readImage($tmpFile); + })->map(function (Image $image, $index) use ($originalNames, $round) { + $fish = new Fish(); + + $this->uploader->upload($fish, $image); + + $fish->name = explode('.', $originalNames[$index])[0]; + $fish->round()->associate($round); + Placement::random()->assign($fish); + $fish->placement_valid_since = Carbon::now(); + $fish->save(); + + return $fish; + })->all(); + } finally { + foreach ($filesToUnlink as $tmpFile) { + @unlink($tmpFile); + } + } + } + + public function delete(Fish $fish): void + { + $this->uploader->remove($fish); + + $fish->delete(); + } + + public function storeDefaultData(Round $round): void + { + $now = Carbon::now(); + + foreach (self::BASE_IMAGES as $index => $originalImagePath) { + $fish = new Fish(); + + $image = $this->readImage(__DIR__ . '/../../resources/images/' . $originalImagePath); + $this->uploader->upload($fish, $image); + + $fish->round_id = $round->id; + $fish->name = $this->translator->trans('xxb-catch-the-fish.api.default-fish-name', [ + '{number}' => $index + 1, + ]); + Placement::random()->assign($fish); + $fish->placement_valid_since = $now; + $fish->save(); + } + } + + protected function imageManager(): ImageManager + { + return resolve(ImageManager::class); + } + + protected function readImage(string $path): Image + { + $manager = $this->imageManager(); + + if (method_exists($manager, 'read')) { + return $manager->read($path); + } + + return $manager->make($path); + } +} diff --git a/src/Repositories/Placement.php b/src/Repositories/Placement.php new file mode 100644 index 0000000..2680122 --- /dev/null +++ b/src/Repositories/Placement.php @@ -0,0 +1,296 @@ +get("catch-the-fish.$key") ?? ''; + + if ($value === '') { + return $default; + } + + return (int)$value; + } + + protected static function settingDiscussionAgeDays(): int + { + return self::intSettingWithDefault('discussionAgeDays', 14); + } + + protected static function settingPostAgeDays(): int + { + return self::intSettingWithDefault('postAgeDays', 14); + } + + protected static function settingPostProbability(): int + { + return self::intSettingWithDefault('postProbability', 50); + } + + protected static function settingUserAgeDays() + { + return resolve(SettingsRepositoryInterface::class)->get('catch-the-fish.userAgeDays', 14); + } + + protected static function settingUserProbability(): int + { + return self::intSettingWithDefault('userProbability', 33); + } + + protected static function settingDiscussionTags() + { + return json_decode(resolve(SettingsRepositoryInterface::class)->get('catch-the-fish.discussionTags') ?: '[]', true); + } + + public function assertValid(): void + { + $translator = resolve(Translator::class); + + if (!is_null($this->discussionId) + !is_null($this->postId) + !is_null($this->userId) !== 1) { + throw new ValidationException([ + 'placement' => $translator->trans(self::TRANSLATION_PREFIX . 'too-many-placement-models'), + ]); + } + + $model = null; + + if (!is_null($this->discussionId)) { + if (!($model = Discussion::query()->where('id', $this->discussionId)->first())) { + throw new ValidationException([ + 'placement' => $translator->trans(self::TRANSLATION_PREFIX . 'invalid-discussion-id'), + ]); + } + } elseif (!is_null($this->postId)) { + if (!($model = Post::query()->where('id', $this->postId)->first())) { + throw new ValidationException([ + 'placement' => $translator->trans(self::TRANSLATION_PREFIX . 'invalid-post-id'), + ]); + } + } elseif (!is_null($this->userId)) { + if (!($model = User::query()->where('id', $this->userId)->first())) { + throw new ValidationException([ + 'placement' => $translator->trans(self::TRANSLATION_PREFIX . 'invalid-user-id'), + ]); + } + } + + if ($model instanceof Discussion || $model instanceof Post) { + if (!is_null($model->hidden_at)) { + throw new ValidationException([ + 'placement' => $translator->trans(self::TRANSLATION_PREFIX . 'model-deleted'), + ]); + } + + if ($model->is_private) { + throw new ValidationException([ + 'placement' => $translator->trans(self::TRANSLATION_PREFIX . 'model-private'), + ]); + } + + $extensions = resolve(ExtensionManager::class); + + $tagSettings = self::settingDiscussionTags(); + + if ($extensions->isEnabled('flarum-tags') && is_array($tagSettings) && count($tagSettings)) { + if ($model instanceof Post) { + $discussion = $model->discussion; + } else { + $discussion = $model; + } + + if (!$discussion->tags()->whereIn('id', Arr::pluck($tagSettings, 'tagId'))->exists()) { + throw new ValidationException([ + 'placement' => $translator->trans(self::TRANSLATION_PREFIX . 'tag-not-allowed'), + ]); + } + } + } + + if ($model instanceof Discussion) { + if (is_null($model->last_posted_at) || $model->last_posted_at->lt(Carbon::now()->subDays(self::settingDiscussionAgeDays()))) { + throw new ValidationException([ + 'placement' => $translator->trans(self::TRANSLATION_PREFIX . 'inactive-discussion', [ + '{days}' => self::settingDiscussionAgeDays(), + ]), + ]); + } + } elseif ($model instanceof Post) { + if ($model->created_at->lt(Carbon::now()->subDays(self::settingPostAgeDays()))) { + throw new ValidationException([ + 'placement' => $translator->trans(self::TRANSLATION_PREFIX . 'inactive-post', [ + '{days}' => self::settingPostAgeDays(), + ]), + ]); + } + + if ($model->type !== 'comment') { + throw new ValidationException([ + 'placement' => $translator->trans(self::TRANSLATION_PREFIX . 'non-comment-post'), + ]); + } + } elseif ($model instanceof User) { + if (is_null($model->last_seen_at) || $model->last_seen_at->lt(Carbon::now()->subDays(self::settingUserAgeDays()))) { + throw new ValidationException([ + 'placement' => $translator->trans(self::TRANSLATION_PREFIX . 'inactive-user', [ + '{days}' => self::settingUserAgeDays(), + ]), + ]); + } + + $extensions = resolve(ExtensionManager::class); + + if ($extensions->isEnabled('flarum-suspend') && !is_null($model->suspended_until) && $model->suspended_until->gt(Carbon::now())) { + throw new ValidationException([ + 'placement' => $translator->trans(self::TRANSLATION_PREFIX . 'user-suspended'), + ]); + } + } + } + + protected static function randomUser(): ?User + { + $query = User::query() + ->where('last_seen_at', '>', Carbon::now()->subDays(self::settingUserAgeDays())) + ->whereHas('posts'); + + $extensions = resolve(ExtensionManager::class); + + if ($extensions->isEnabled('flarum-suspend')) { + $query->whereNull('suspended_until'); + } + + $count = $query->count(); + + if ($count === 0) { + return null; + } + + return $query->offset(random_int(0, $count - 1))->limit(1)->first(); + } + + protected static function randomDiscussion($tagId = null): Discussion + { + $query = Discussion::query() + ->where('is_private', false) + ->whereNull('hidden_at') + ->where('last_posted_at', '>', Carbon::now()->subDays(self::settingDiscussionAgeDays())); + + if ($tagId) { + $query->whereHas('tags', function (Builder $query) use ($tagId) { + $query->where('id', $tagId); + }); + } + + $count = $query->count(); + + if ($count === 0) { + return null; + } + + return $query->offset(random_int(0, $count - 1))->limit(1)->first(); + } + + protected static function randomPost(Discussion $discussion): ?Post + { + $query = $discussion + ->comments() + ->where('created_at', '>', Carbon::now()->subDays(self::settingPostAgeDays())); + + $count = $query->count(); + + if ($count === 0) { + return null; + } + + return $query->offset(random_int(0, $count - 1))->limit(1)->first(); + } + + public static function random(): self + { + $placement = new self(); + + if (random_int(0, 99) < self::settingUserProbability()) { + $user = self::randomUser(); + + if ($user) { + $placement->userId = $user->id; + + return $placement; + } + } + + $extensions = resolve(ExtensionManager::class); + + $tagSettings = self::settingDiscussionTags(); + + $discussion = null; + + if ($extensions->isEnabled('flarum-tags') && is_array($tagSettings) && count($tagSettings)) { + foreach ($tagSettings as $index => $tagSetting) { + + if ($index !== count($tagSettings) - 1 && random_int(0, 99) >= Arr::get($tagSetting, 'probability')) { + continue; + } + + $discussion = self::randomDiscussion(Arr::get($tagSetting, 'tagId')); + + if ($discussion) { + break; + } + } + } else { + $discussion = self::randomDiscussion(); + } + + if (!$discussion) { + $translator = resolve(Translator::class); + + throw new ValidationException([ + 'placement' => $translator->trans(self::TRANSLATION_PREFIX . 'random-error'), + ]); + } + + if (random_int(0, 99) < self::settingPostProbability()) { + $post = self::randomPost($discussion); + + if ($post) { + $placement->postId = $post->id; + + return $placement; + } + } + + $placement->discussionId = $discussion->id; + + return $placement; + } + + public function assign(Fish $fish) + { + $fish->discussion_id_placement = $this->discussionId; + $fish->post_id_placement = $this->postId; + $fish->user_id_placement = $this->userId; + } +} diff --git a/src/Repositories/PlacementRepository.php b/src/Repositories/PlacementRepository.php new file mode 100644 index 0000000..1b8b783 --- /dev/null +++ b/src/Repositories/PlacementRepository.php @@ -0,0 +1,123 @@ +settings = $settings; + $this->validator = $validator; + $this->translator = $translator; + } + + protected function assertFishIsAtPlacement(Fish $fish, array $placement): void + { + foreach ([ + 'discussion_id', + 'post_id', + 'user_id', + ] as $key) { + $value = $fish->{$key . '_placement'}; + if ($value !== null && $value === Arr::get($placement, $key)) { + return; + } + } + + throw new ValidationException([ + 'placement' => $this->translator->trans('xxb-catch-the-fish.api.wrong-catch-placement'), + ]); + } + + public function catch(User $actor, Fish $fish, array $placement): Fish + { + $actor->assertCan('catch', $fish); + + $this->assertFishIsAtPlacement($fish, $placement); + + $fish->user_id_last_placement = null; + $fish->last_caught_at = Carbon::now(); + $fish->lastUserCatch()->associate($actor); + Placement::random()->assign($fish); + + $placementValidSince = Carbon::now(); + + if ($actor->can('catchthefish.choose-place') || $actor->can('catchthefish.choose-name')) { + $placementValidSince->addMinutes((int) $this->settings->get('catch-the-fish.autoPlacedAfterMinutes', 5)); + } + + $fish->placement_valid_since = $placementValidSince; + + $fish->save(); + + $ranking = Ranking::firstOrCreate( + [ + 'round_id' => $fish->round->id, + 'user_id' => $actor->id, + ], + [ + 'catch_count' => 0, + ] + ); + + $ranking->increment('catch_count'); + + return $fish; + } + + public function place(User $actor, Fish $fish, array $attributes): Fish + { + $fishBeforeUpdate = clone $fish; + + if (Arr::has($attributes, 'placement')) { + $actor->assertCan('place', $fishBeforeUpdate); + + if (Arr::get($attributes, 'placement') !== 'random') { + $placement = new Placement(); + $placement->discussionId = Arr::get($attributes, 'placement.discussion_id'); + $placement->postId = Arr::get($attributes, 'placement.post_id'); + $placement->userId = Arr::get($attributes, 'placement.user_id'); + + $placement->assertValid(); + + $placement->assign($fish); + $fish->lastUserPlacement()->associate($actor); + } + + $fish->placement_valid_since = Carbon::now(); + } + + if (Arr::has($attributes, 'name')) { + $actor->assertCan('name', $fishBeforeUpdate); + + $this->validator->assertValid($attributes); + + $fish->name = Arr::get($attributes, 'name'); + $fish->lastUserNaming()->associate($actor); + + if (!$actor->can('place', $fishBeforeUpdate)) { + $fish->placement_valid_since = Carbon::now(); + } + } + + if ($fish->isDirty()) { + $fish->save(); + } + + return $fish; + } +} diff --git a/src/Repositories/RankingRepository.php b/src/Repositories/RankingRepository.php new file mode 100644 index 0000000..8e8edb9 --- /dev/null +++ b/src/Repositories/RankingRepository.php @@ -0,0 +1,15 @@ +rankings() + ->orderBy('catch_count', 'desc') + ->get(); + } +} diff --git a/src/Repositories/RoundRepository.php b/src/Repositories/RoundRepository.php new file mode 100644 index 0000000..04c6462 --- /dev/null +++ b/src/Repositories/RoundRepository.php @@ -0,0 +1,93 @@ +validator = $validator; + $this->fishRepository = $fishRepository; + $this->uploader = $uploader; + } + + public function all($limit = null, $offset = 0) + { + $query = Round::query(); + + if ($limit !== null) { + $query->limit($limit)->offset($offset); + } + + return $query->get(); + } + + public function allActive(): Collection + { + return Round::activeRound()->get(); + } + + public function findOrFail($id): Round + { + return Round::query()->where('id', $id)->firstOrFail(); + } + + protected function parseAttributes(array $attributes): array + { + $return = Arr::only($attributes, 'name'); + + if (Arr::has($attributes, 'starts_at')) { + $return['starts_at'] = Carbon::parse($attributes['starts_at']); + } + + if (Arr::has($attributes, 'ends_at')) { + $return['ends_at'] = Carbon::parse($attributes['ends_at']); + } + + return $return; + } + + public function store(array $attributes): Round + { + $this->validator->assertValid($attributes); + + $round = new Round($this->parseAttributes($attributes)); + $round->save(); + + if (Arr::get($attributes, 'include_starting_pack')) { + $this->fishRepository->storeDefaultData($round); + } + + return $round; + } + + public function update(Round $round, array $attributes): Round + { + $this->validator->assertValid($attributes); + + $round->fill($this->parseAttributes($attributes)); + $round->save(); + + return $round; + } + + public function delete(Round $round): void + { + foreach ($round->fishes as $fish) { + $this->uploader->remove($fish); + } + + $round->delete(); + } +} diff --git a/src/Round.php b/src/Round.php new file mode 100644 index 0000000..069125c --- /dev/null +++ b/src/Round.php @@ -0,0 +1,54 @@ + 'datetime', + 'ends_at' => 'datetime', + ]; + + protected $fillable = [ + 'name', + 'starts_at', + 'ends_at', + ]; + + public $timestamps = true; + + public function fishes(): HasMany + { + return $this->hasMany(Fish::class); + } + + public function rankings(): HasMany + { + return $this->hasMany(Ranking::class); + } + + public function userRanking(User $user): ?Ranking + { + return $this->rankings()->where('user_id', $user->id)->first(); + } + + public function scopeActiveRound(Builder $query): Builder + { + $now = Carbon::now(); + + return $query->where('ends_at', '>', $now) + ->where(function (Builder $query) use ($now) { + $query->whereNull('starts_at') + ->orWhere('starts_at', '<', $now); + }); + } +} diff --git a/src/Serializers/AbstractSerializer.php b/src/Serializers/AbstractSerializer.php new file mode 100644 index 0000000..ff22248 --- /dev/null +++ b/src/Serializers/AbstractSerializer.php @@ -0,0 +1,100 @@ +request = $request; + $this->actor = RequestUtil::getActor($request); + } + + public function getType($model): string + { + return $this->type; + } + + public function getId($model): ?string + { + return $model && isset($model->id) ? (string) $model->id : null; + } + + public function getAttributes($model, array $fields = null): array + { + return $this->getDefaultAttributes($model); + } + + public function getLinks($model): array + { + return []; + } + + public function getMeta($model): array + { + return []; + } + + public function getRelationship($model, $name): ?Relationship + { + if (!method_exists($this, $name)) { + return null; + } + + return $this->$name($model); + } + + protected function getDefaultAttributes($model): array + { + return []; + } + + protected function formatDate($date): ?string + { + return $date instanceof DateTimeInterface ? $date->format(DateTimeInterface::ATOM) : null; + } + + protected function resolveSerializer($serializer, ...$arguments) + { + return $this->resolveSerializerClass($serializer); + } + + protected function resolveSerializerClass(string $serializer) + { + $instance = resolve($serializer); + + if ($this->request && method_exists($instance, 'setRequest')) { + $instance->setRequest($this->request); + } + + return $instance; + } + + protected function hasOne($model, string $serializer, ?string $relationship = null): ?Relationship + { + $relationship = $relationship ?: lcfirst(class_basename($serializer)); + $data = $model->{$relationship}; + + if (!$data) { + return null; + } + + return new Relationship(new Resource($data, $this->resolveSerializerClass($serializer))); + } + + protected function buildRelationship($model, string $serializer, string $relationship): ?Relationship + { + return $this->hasOne($model, $serializer, $relationship); + } +} diff --git a/src/Serializers/BasicDiscussionSerializer.php b/src/Serializers/BasicDiscussionSerializer.php new file mode 100644 index 0000000..336ad03 --- /dev/null +++ b/src/Serializers/BasicDiscussionSerializer.php @@ -0,0 +1,17 @@ + $discussion->title, + 'slug' => method_exists($discussion, 'slug') ? $discussion->slug() : (string) $discussion->id, + 'commentCount' => $discussion->comment_count, + ]; + } +} diff --git a/src/Serializers/BasicUserSerializer.php b/src/Serializers/BasicUserSerializer.php new file mode 100644 index 0000000..76ef1f5 --- /dev/null +++ b/src/Serializers/BasicUserSerializer.php @@ -0,0 +1,7 @@ +actor->can($ability, $arguments); + } catch (ValidationException $exception) { + return false; + } + } + + protected function getDefaultAttributes($fish): array + { + $canPlace = $this->actorCan('place', $fish); + + return [ + 'name' => $fish->name, + 'image_url' => $fish->image_url, + 'placement' => $this->actorCan('catch', $fish) || $this->actorCan('catchthefish.moderate') ? [ + 'discussion_id' => $fish->discussion_id_placement, + 'post_id' => $fish->post_id_placement, + 'user_id' => $fish->user_id_placement, + ] : null, + 'canSee' => $this->actorCan('see', $fish), + 'canCatch' => $this->actorCan('catch', $fish), + 'canName' => $this->actorCan('name', $fish), + 'canPlace' => $canPlace, + 'placeUntil' => $canPlace ? $this->formatDate($fish->placement_valid_since) : null, + ]; + } + + public function round($fish): ?Relationship + { + return $this->buildRelationship($fish, RoundSerializer::class, 'round'); + } + + public function lastUserPlacement($fish): ?Relationship + { + return $this->buildRelationship($fish, UserSerializer::class, 'lastUserPlacement'); + } + + public function lastUserNaming($fish): ?Relationship + { + return $this->buildRelationship($fish, UserSerializer::class, 'lastUserNaming'); + } + + protected function placementRelationship($data, string $serializer): ?Relationship + { + if (!$data) { + return null; + } + + $serializer = $this->resolveSerializerClass($serializer); + + $element = new Resource($data, $serializer); + + return new Relationship($element); + } + + public function placement(Fish $fish): ?Relationship + { + if ($fish->discussion_id_placement) { + return $this->placementRelationship($fish->placementDiscussion()->whereVisibleTo($this->actor)->first(), BasicDiscussionSerializer::class); + } + + if ($fish->post_id_placement) { + return $this->placementRelationship($fish->placementPost()->whereVisibleTo($this->actor)->first(), PostSerializer::class); + } + + if ($fish->user_id_placement) { + return $this->placementRelationship($fish->placementUser()->whereVisibleTo($this->actor)->first(), BasicUserSerializer::class); + } + + return null; + } +} diff --git a/src/Serializers/PostSerializer.php b/src/Serializers/PostSerializer.php new file mode 100644 index 0000000..652cbcc --- /dev/null +++ b/src/Serializers/PostSerializer.php @@ -0,0 +1,23 @@ + $post->number, + 'createdAt' => $this->formatDate($post->created_at), + ]; + } + + public function discussion($post): ?Relationship + { + return $this->hasOne($post, BasicDiscussionSerializer::class, 'discussion'); + } +} diff --git a/src/Serializers/RankingSerializer.php b/src/Serializers/RankingSerializer.php new file mode 100644 index 0000000..c8764f1 --- /dev/null +++ b/src/Serializers/RankingSerializer.php @@ -0,0 +1,23 @@ + $ranking->catch_count, + ]; + } + + public function user(Ranking $ranking): ?Relationship + { + return $this->hasOne($ranking, UserSerializer::class, 'user'); + } +} diff --git a/src/Serializers/RoundSerializer.php b/src/Serializers/RoundSerializer.php new file mode 100644 index 0000000..0843c23 --- /dev/null +++ b/src/Serializers/RoundSerializer.php @@ -0,0 +1,36 @@ +userRanking($this->actor); + + return [ + 'name' => $round->name, + 'starts_at' => $this->formatDate($round->starts_at), + 'ends_at' => $this->formatDate($round->ends_at), + 'my_catch_count' => $ranking ? $ranking->catch_count : 0, + ]; + } + + public function myRanking(Round $round): ?Relationship + { + $data = $round->userRanking($this->actor); + + if (!$data) { + return null; + } + + return new Relationship(new Resource($data, $this->resolveSerializer(RankingSerializer::class, $round, $data))); + } +} diff --git a/src/Serializers/UserSerializer.php b/src/Serializers/UserSerializer.php new file mode 100644 index 0000000..06e7a92 --- /dev/null +++ b/src/Serializers/UserSerializer.php @@ -0,0 +1,18 @@ + $user->username, + 'displayName' => $user->display_name ?: $user->username, + 'avatarUrl' => $user->avatar_url, + 'slug' => method_exists($user, 'slug') ? $user->slug() : (string) $user->id, + ]; + } +} diff --git a/src/Tobscure/JsonApi/AbstractSerializer.php b/src/Tobscure/JsonApi/AbstractSerializer.php new file mode 100644 index 0000000..6e343cc --- /dev/null +++ b/src/Tobscure/JsonApi/AbstractSerializer.php @@ -0,0 +1,106 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tobscure\JsonApi; + +use LogicException; + +abstract class AbstractSerializer implements SerializerInterface +{ + /** + * The type. + * + * @var string + */ + protected $type; + + /** + * {@inheritdoc} + */ + public function getType($model) + { + return $this->type; + } + + /** + * {@inheritdoc} + */ + public function getId($model) + { + return $model->id; + } + + /** + * {@inheritdoc} + */ + public function getAttributes($model, array $fields = null) + { + return []; + } + + /** + * {@inheritdoc} + */ + public function getLinks($model) + { + return []; + } + + /** + * {@inheritdoc} + */ + public function getMeta($model) + { + return []; + } + + /** + * {@inheritdoc} + * + * @throws \LogicException + */ + public function getRelationship($model, $name) + { + $method = $this->getRelationshipMethodName($name); + + if (method_exists($this, $method)) { + $relationship = $this->$method($model); + + if ($relationship !== null && ! ($relationship instanceof Relationship)) { + throw new LogicException('Relationship method must return null or an instance of Tobscure\JsonApi\Relationship'); + } + + return $relationship; + } + } + + /** + * Get the serializer method name for the given relationship. + * + * snake_case and kebab-case are converted into camelCase. + * + * @param string $name + * + * @return string + */ + private function getRelationshipMethodName($name) + { + if (stripos($name, '-')) { + $name = lcfirst(implode('', array_map('ucfirst', explode('-', $name)))); + } + + if (stripos($name, '_')) { + $name = lcfirst(implode('', array_map('ucfirst', explode('_', $name)))); + } + + return $name; + } +} diff --git a/src/Tobscure/JsonApi/Collection.php b/src/Tobscure/JsonApi/Collection.php new file mode 100644 index 0000000..33f20ba --- /dev/null +++ b/src/Tobscure/JsonApi/Collection.php @@ -0,0 +1,142 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tobscure\JsonApi; + +class Collection implements ElementInterface +{ + /** + * @var array + */ + protected $resources = []; + + /** + * Create a new collection instance. + * + * @param mixed $data + * @param \Tobscure\JsonApi\SerializerInterface $serializer + */ + public function __construct($data, SerializerInterface $serializer) + { + $this->resources = $this->buildResources($data, $serializer); + } + + /** + * Convert an array of raw data to Resource objects. + * + * @param mixed $data + * @param SerializerInterface $serializer + * + * @return \Tobscure\JsonApi\Resource[] + */ + protected function buildResources($data, SerializerInterface $serializer) + { + $resources = []; + + foreach ($data as $resource) { + if (! ($resource instanceof Resource)) { + $resource = new Resource($resource, $serializer); + } + + $resources[] = $resource; + } + + return $resources; + } + + /** + * {@inheritdoc} + */ + public function getResources() + { + return $this->resources; + } + + /** + * Set the resources array. + * + * @param array $resources + * + * @return void + */ + public function setResources($resources) + { + $this->resources = $resources; + } + + /** + * Request a relationship to be included for all resources. + * + * @param string|array $relationships + * + * @return $this + */ + public function with($relationships) + { + foreach ($this->resources as $resource) { + $resource->with($relationships); + } + + return $this; + } + + /** + * Request a relationship to be identified for all resources. + * + * @param string|array $relationships + * + * @return $this + */ + public function identify($relationships) + { + foreach ($this->resources as $resource) { + $resource->identify($relationships); + } + + return $this; + } + + /** + * Request a restricted set of fields. + * + * @param array|null $fields + * + * @return $this + */ + public function fields($fields) + { + foreach ($this->resources as $resource) { + $resource->fields($fields); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function toArray() + { + return array_map(function (Resource $resource) { + return $resource->toArray(); + }, $this->resources); + } + + /** + * {@inheritdoc} + */ + public function toIdentifier() + { + return array_map(function (Resource $resource) { + return $resource->toIdentifier(); + }, $this->resources); + } +} diff --git a/src/Tobscure/JsonApi/Document.php b/src/Tobscure/JsonApi/Document.php new file mode 100644 index 0000000..cc524d3 --- /dev/null +++ b/src/Tobscure/JsonApi/Document.php @@ -0,0 +1,231 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tobscure\JsonApi; + +use JsonSerializable; + +class Document implements JsonSerializable +{ + use LinksTrait; + use MetaTrait; + + /** + * The included array. + * + * @var array + */ + protected $included = []; + + /** + * The errors array. + * + * @var array + */ + protected $errors; + + /** + * The jsonapi array. + * + * @var array + */ + protected $jsonapi; + + /** + * The data object. + * + * @var ElementInterface + */ + protected $data; + + /** + * @param ElementInterface $data + */ + public function __construct(ElementInterface $data = null) + { + $this->data = $data; + } + + /** + * Get included resources. + * + * @param \Tobscure\JsonApi\ElementInterface $element + * @param bool $includeParent + * + * @return \Tobscure\JsonApi\Resource[] + */ + protected function getIncluded(ElementInterface $element, $includeParent = false) + { + $included = []; + + foreach ($element->getResources() as $resource) { + if ($resource->isIdentifier()) { + continue; + } + + if ($includeParent) { + $included = $this->mergeResource($included, $resource); + } else { + $type = $resource->getType(); + $id = $resource->getId(); + } + + foreach ($resource->getUnfilteredRelationships() as $relationship) { + $includedElement = $relationship->getData(); + + if (! $includedElement instanceof ElementInterface) { + continue; + } + + foreach ($this->getIncluded($includedElement, true) as $child) { + // If this resource is the same as the top-level "data" + // resource, then we don't want it to show up again in the + // "included" array. + if (! $includeParent && $child->getType() === $type && $child->getId() === $id) { + continue; + } + + $included = $this->mergeResource($included, $child); + } + } + } + + $flattened = []; + + array_walk_recursive($included, function ($a) use (&$flattened) { + $flattened[] = $a; + }); + + return $flattened; + } + + /** + * @param \Tobscure\JsonApi\Resource[] $resources + * @param \Tobscure\JsonApi\Resource $newResource + * + * @return \Tobscure\JsonApi\Resource[] + */ + protected function mergeResource(array $resources, Resource $newResource) + { + $type = $newResource->getType(); + $id = $newResource->getId(); + + if (isset($resources[$type][$id])) { + $resources[$type][$id]->merge($newResource); + } else { + $resources[$type][$id] = $newResource; + } + + return $resources; + } + + /** + * Set the data object. + * + * @param \Tobscure\JsonApi\ElementInterface $element + * + * @return $this + */ + public function setData(ElementInterface $element) + { + $this->data = $element; + + return $this; + } + + /** + * Set the errors array. + * + * @param array $errors + * + * @return $this + */ + public function setErrors($errors) + { + $this->errors = $errors; + + return $this; + } + + /** + * Set the jsonapi array. + * + * @param array $jsonapi + * + * @return $this + */ + public function setJsonapi($jsonapi) + { + $this->jsonapi = $jsonapi; + + return $this; + } + + /** + * Map everything to arrays. + * + * @return array + */ + public function toArray() + { + $document = []; + + if (! empty($this->links)) { + $document['links'] = $this->links; + } + + if (! empty($this->data)) { + $document['data'] = $this->data->toArray(); + + $resources = $this->getIncluded($this->data); + + if (count($resources)) { + $document['included'] = array_map(function (Resource $resource) { + return $resource->toArray(); + }, $resources); + } + } + + if (! empty($this->meta)) { + $document['meta'] = $this->meta; + } + + if (! empty($this->errors)) { + $document['errors'] = $this->errors; + } + + if (! empty($this->jsonapi)) { + $document['jsonapi'] = $this->jsonapi; + } + + return $document; + } + + /** + * Map to string. + * + * @return string + */ + public function __toString() + { + return json_encode($this->toArray()); + } + + /** + * Serialize for JSON usage. + * + * @return array + */ + public function jsonSerialize() + { + return $this->toArray(); + } +} diff --git a/src/Tobscure/JsonApi/ElementInterface.php b/src/Tobscure/JsonApi/ElementInterface.php new file mode 100644 index 0000000..81b74ba --- /dev/null +++ b/src/Tobscure/JsonApi/ElementInterface.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tobscure\JsonApi; + +interface ElementInterface +{ + /** + * Get the resources array. + * + * @return array + */ + public function getResources(); + + /** + * Map to a "resource object" array. + * + * @return array + */ + public function toArray(); + + /** + * Map to a "resource object identifier" array. + * + * @return array + */ + public function toIdentifier(); + + /** + * Request a relationship to be included. + * + * @param string|array $relationships + * + * @return $this + */ + public function with($relationships); + + /** + * Request a restricted set of fields. + * + * @param array|null $fields + * + * @return $this + */ + public function fields($fields); +} diff --git a/src/Tobscure/JsonApi/ErrorHandler.php b/src/Tobscure/JsonApi/ErrorHandler.php new file mode 100644 index 0000000..1e3492d --- /dev/null +++ b/src/Tobscure/JsonApi/ErrorHandler.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tobscure\JsonApi; + +use Exception; +use RuntimeException; +use Tobscure\JsonApi\Exception\Handler\ExceptionHandlerInterface; + +class ErrorHandler +{ + /** + * Stores the valid handlers. + * + * @var \Tobscure\JsonApi\Exception\Handler\ExceptionHandlerInterface[] + */ + private $handlers = []; + + /** + * Handle the exception provided. + * + * @param Exception $e + * + * @throws RuntimeException + * + * @return \Tobscure\JsonApi\Exception\Handler\ResponseBag + */ + public function handle(Exception $e) + { + foreach ($this->handlers as $handler) { + if ($handler->manages($e)) { + return $handler->handle($e); + } + } + + throw new RuntimeException('Exception handler for '.get_class($e).' not found.'); + } + + /** + * Register a new exception handler. + * + * @param \Tobscure\JsonApi\Exception\Handler\ExceptionHandlerInterface $handler + * + * @return void + */ + public function registerHandler(ExceptionHandlerInterface $handler) + { + $this->handlers[] = $handler; + } +} diff --git a/src/Tobscure/JsonApi/Exception/InvalidParameterException.php b/src/Tobscure/JsonApi/Exception/InvalidParameterException.php new file mode 100644 index 0000000..72027c0 --- /dev/null +++ b/src/Tobscure/JsonApi/Exception/InvalidParameterException.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tobscure\JsonApi\Exception; + +use Exception; + +class InvalidParameterException extends Exception +{ + /** + * @var string The parameter that caused this exception. + */ + private $invalidParameter; + + /** + * {@inheritdoc} + * + * @param string $invalidParameter The parameter that caused this exception. + */ + public function __construct($message = '', $code = 0, $previous = null, $invalidParameter = '') + { + parent::__construct($message, $code, $previous); + + $this->invalidParameter = $invalidParameter; + } + + /** + * @return string The parameter that caused this exception. + */ + public function getInvalidParameter() + { + return $this->invalidParameter; + } +} diff --git a/src/Tobscure/JsonApi/LinksTrait.php b/src/Tobscure/JsonApi/LinksTrait.php new file mode 100644 index 0000000..1f22171 --- /dev/null +++ b/src/Tobscure/JsonApi/LinksTrait.php @@ -0,0 +1,135 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tobscure\JsonApi; + +trait LinksTrait +{ + /** + * The links array. + * + * @var array + */ + protected $links; + + /** + * Get the links. + * + * @return array + */ + public function getLinks() + { + return $this->links; + } + + /** + * Set the links. + * + * @param array $links + * + * @return $this + */ + public function setLinks(array $links) + { + $this->links = $links; + + return $this; + } + + /** + * Add a link. + * + * @param string $key + * @param string $value + * + * @return $this + */ + public function addLink($key, $value) + { + $this->links[$key] = $value; + + return $this; + } + + /** + * Add pagination links (first, prev, next, and last). + * + * @param string $url The base URL for pagination links. + * @param array $queryParams The query params provided in the request. + * @param int $offset The current offset. + * @param int $limit The current limit. + * @param int|null $total The total number of results, or null if unknown. + * + * @return void + */ + public function addPaginationLinks($url, array $queryParams, $offset, $limit, $total = null) + { + if (isset($queryParams['page']['number'])) { + $offset = floor($offset / $limit) * $limit; + } + + $this->addPaginationLink('first', $url, $queryParams, 0, $limit); + + if ($offset > 0) { + $this->addPaginationLink('prev', $url, $queryParams, max(0, $offset - $limit), $limit); + } + + if ($total === null || $offset + $limit < $total) { + $this->addPaginationLink('next', $url, $queryParams, $offset + $limit, $limit); + } + + if ($total) { + $this->addPaginationLink('last', $url, $queryParams, floor(($total - 1) / $limit) * $limit, $limit); + } + } + + /** + * Add a pagination link. + * + * @param string $name The name of the link. + * @param string $url The base URL for pagination links. + * @param array $queryParams The query params provided in the request. + * @param int $offset The offset to link to. + * @param int $limit The current limit. + * + * @return void + */ + protected function addPaginationLink($name, $url, array $queryParams, $offset, $limit) + { + if (! isset($queryParams['page']) || ! is_array($queryParams['page'])) { + $queryParams['page'] = []; + } + + $page = &$queryParams['page']; + + if (isset($page['number'])) { + $page['number'] = floor($offset / $limit) + 1; + + if ($page['number'] <= 1) { + unset($page['number']); + } + } else { + $page['offset'] = $offset; + + if ($page['offset'] <= 0) { + unset($page['offset']); + } + } + + if (isset($page['limit'])) { + $page['limit'] = $limit; + } + + $queryString = http_build_query($queryParams); + + $this->addLink($name, $url.($queryString ? '?'.$queryString : '')); + } +} diff --git a/src/Tobscure/JsonApi/MetaTrait.php b/src/Tobscure/JsonApi/MetaTrait.php new file mode 100644 index 0000000..5572446 --- /dev/null +++ b/src/Tobscure/JsonApi/MetaTrait.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tobscure\JsonApi; + +trait MetaTrait +{ + /** + * The meta data array. + * + * @var array + */ + protected $meta; + + /** + * Get the meta. + * + * @return array + */ + public function getMeta() + { + return $this->meta; + } + + /** + * Set the meta data array. + * + * @param array $meta + * + * @return $this + */ + public function setMeta(array $meta) + { + $this->meta = $meta; + + return $this; + } + + /** + * Add meta data. + * + * @param string $key + * @param string $value + * + * @return $this + */ + public function addMeta($key, $value) + { + $this->meta[$key] = $value; + + return $this; + } +} diff --git a/src/Tobscure/JsonApi/Parameters.php b/src/Tobscure/JsonApi/Parameters.php new file mode 100644 index 0000000..0280e47 --- /dev/null +++ b/src/Tobscure/JsonApi/Parameters.php @@ -0,0 +1,220 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tobscure\JsonApi; + +use Tobscure\JsonApi\Exception\InvalidParameterException; + +class Parameters +{ + /** + * @var array + */ + protected $input; + + /** + * @param array $input + */ + public function __construct(array $input) + { + $this->input = $input; + } + + /** + * Get the includes. + * + * @param array $available + * + * @throws \Tobscure\JsonApi\Exception\InvalidParameterException + * + * @return array + */ + public function getInclude(array $available = []) + { + if ($include = $this->getInput('include')) { + $relationships = explode(',', $include); + + $invalid = array_diff($relationships, $available); + + if (count($invalid)) { + throw new InvalidParameterException( + 'Invalid includes ['.implode(',', $invalid).']', + 1, + null, + 'include' + ); + } + + return $relationships; + } + + return []; + } + + /** + * Get number of offset. + * + * @param int|null $perPage + * + * @throws \Tobscure\JsonApi\Exception\InvalidParameterException + * + * @return int + */ + public function getOffset($perPage = null) + { + if ($perPage && ($offset = $this->getOffsetFromNumber($perPage))) { + return $offset; + } + + $offset = (int) $this->getPage('offset'); + + if ($offset < 0) { + throw new InvalidParameterException('page[offset] must be >=0', 2, null, 'page[offset]'); + } + + return $offset; + } + + /** + * Calculate the offset based on the page[number] parameter. + * + * @param int $perPage + * + * @throws \Tobscure\JsonApi\Exception\InvalidParameterException + * + * @return int + */ + protected function getOffsetFromNumber($perPage) + { + $page = (int) $this->getPage('number'); + + if ($page <= 1) { + return 0; + } + + return ($page - 1) * $perPage; + } + + /** + * Get the limit. + * + * @param int|null $max + * + * @return int|null + */ + public function getLimit($max = null) + { + $limit = $this->getPage('limit') ?: $this->getPage('size') ?: null; + + if ($limit && $max) { + $limit = min($max, $limit); + } + + return $limit; + } + + /** + * Get the sort. + * + * @param array $available + * + * @throws \Tobscure\JsonApi\Exception\InvalidParameterException + * + * @return array + */ + public function getSort(array $available = []) + { + $sort = []; + + if ($input = $this->getInput('sort')) { + $fields = explode(',', $input); + + foreach ($fields as $field) { + if (substr($field, 0, 1) === '-') { + $field = substr($field, 1); + $order = 'desc'; + } else { + $order = 'asc'; + } + + $sort[$field] = $order; + } + + $invalid = array_diff(array_keys($sort), $available); + + if (count($invalid)) { + throw new InvalidParameterException( + 'Invalid sort fields ['.implode(',', $invalid).']', + 3, + null, + 'sort' + ); + } + } + + return $sort; + } + + /** + * Get the fields requested for inclusion. + * + * @return array + */ + public function getFields() + { + $fields = $this->getInput('fields'); + + if (! is_array($fields)) { + return []; + } + + return array_map(function ($fields) { + return explode(',', $fields); + }, $fields); + } + + /** + * Get a filter item. + * + * @return mixed + */ + public function getFilter() + { + return $this->getInput('filter'); + } + + /** + * Get an input item. + * + * @param string $key + * @param null $default + * + * @return mixed + */ + protected function getInput($key, $default = null) + { + return isset($this->input[$key]) ? $this->input[$key] : $default; + } + + /** + * Get the page. + * + * @param string $key + * + * @return string + */ + protected function getPage($key) + { + $page = $this->getInput('page'); + + return isset($page[$key]) ? $page[$key] : ''; + } +} diff --git a/src/Tobscure/JsonApi/Relationship.php b/src/Tobscure/JsonApi/Relationship.php new file mode 100644 index 0000000..29f7fc6 --- /dev/null +++ b/src/Tobscure/JsonApi/Relationship.php @@ -0,0 +1,83 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tobscure\JsonApi; + +class Relationship +{ + use LinksTrait; + use MetaTrait; + + /** + * The data object. + * + * @var \Tobscure\JsonApi\ElementInterface|null + */ + protected $data; + + /** + * Create a new relationship. + * + * @param \Tobscure\JsonApi\ElementInterface|null $data + */ + public function __construct(ElementInterface $data = null) + { + $this->data = $data; + } + + /** + * Get the data object. + * + * @return \Tobscure\JsonApi\ElementInterface|null + */ + public function getData() + { + return $this->data; + } + + /** + * Set the data object. + * + * @param \Tobscure\JsonApi\ElementInterface|null $data + * + * @return $this + */ + public function setData($data) + { + $this->data = $data; + + return $this; + } + + /** + * Map everything to an array. + * + * @return array + */ + public function toArray() + { + $array = []; + + if (! empty($this->data)) { + $array['data'] = $this->data->toIdentifier(); + } + + if (! empty($this->meta)) { + $array['meta'] = $this->meta; + } + + if (! empty($this->links)) { + $array['links'] = $this->links; + } + + return $array; + } +} diff --git a/src/Tobscure/JsonApi/Resource.php b/src/Tobscure/JsonApi/Resource.php new file mode 100644 index 0000000..b52230b --- /dev/null +++ b/src/Tobscure/JsonApi/Resource.php @@ -0,0 +1,406 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tobscure\JsonApi; + +class Resource implements ElementInterface +{ + use LinksTrait; + use MetaTrait; + + /** + * @var mixed + */ + protected $data; + + /** + * @var \Tobscure\JsonApi\SerializerInterface + */ + protected $serializer; + + /** + * A list of relationships to include. + * + * @var array + */ + protected $includes = []; + + /** + * A list of fields to restrict to. + * + * @var array|null + */ + protected $fields; + + /** + * An array of Resources that should be merged into this one. + * + * @var \Tobscure\JsonApi\Resource[] + */ + protected $merged = []; + + /** + * @var \Tobscure\JsonApi\Relationship[] + */ + private $relationships; + + /** + * @param mixed $data + * @param \Tobscure\JsonApi\SerializerInterface $serializer + */ + public function __construct($data, SerializerInterface $serializer) + { + $this->data = $data; + $this->serializer = $serializer; + } + + /** + * {@inheritdoc} + */ + public function getResources() + { + return [$this]; + } + + /** + * {@inheritdoc} + */ + public function toArray() + { + $array = $this->toIdentifier(); + + if (! $this->isIdentifier()) { + $attributes = $this->getAttributes(); + if ($attributes) { + $array['attributes'] = $attributes; + } + } + + $relationships = $this->getRelationshipsAsArray(); + + if (count($relationships)) { + $array['relationships'] = $relationships; + } + + $links = []; + if (! empty($this->links)) { + $links = $this->links; + } + $serializerLinks = $this->serializer->getLinks($this->data); + if (! empty($serializerLinks)) { + $links = array_merge($serializerLinks, $links); + } + if (! empty($links)) { + $array['links'] = $links; + } + + $meta = []; + if (! empty($this->meta)) { + $meta = $this->meta; + } + $serializerMeta = $this->serializer->getMeta($this->data); + if (! empty($serializerMeta)) { + $meta = array_merge($serializerMeta, $meta); + } + if (! empty($meta)) { + $array['meta'] = $meta; + } + + return $array; + } + + /** + * Check whether or not this resource is an identifier (i.e. does it have + * any data attached?). + * + * @return bool + */ + public function isIdentifier() + { + return ! is_object($this->data) && ! is_array($this->data); + } + + /** + * {@inheritdoc} + */ + public function toIdentifier() + { + if (! $this->data) { + return; + } + + $array = [ + 'type' => $this->getType(), + 'id' => $this->getId() + ]; + + if (! empty($this->meta)) { + $array['meta'] = $this->meta; + } + + return $array; + } + + /** + * Get the resource type. + * + * @return string + */ + public function getType() + { + return $this->serializer->getType($this->data); + } + + /** + * Get the resource ID. + * + * @return string + */ + public function getId() + { + if (! is_object($this->data) && ! is_array($this->data)) { + return (string) $this->data; + } + + return (string) $this->serializer->getId($this->data); + } + + /** + * Get the resource attributes. + * + * @return array + */ + public function getAttributes() + { + $attributes = (array) $this->serializer->getAttributes($this->data, $this->getOwnFields()); + + $attributes = $this->filterFields($attributes); + + $attributes = $this->mergeAttributes($attributes); + + return $attributes; + } + + /** + * Get the requested fields for this resource type. + * + * @return array|null + */ + protected function getOwnFields() + { + $type = $this->getType(); + + if (isset($this->fields[$type])) { + return $this->fields[$type]; + } + } + + /** + * Filter the given fields array (attributes or relationships) according + * to the requested fieldset. + * + * @param array $fields + * + * @return array + */ + protected function filterFields(array $fields) + { + if ($requested = $this->getOwnFields()) { + $fields = array_intersect_key($fields, array_flip($requested)); + } + + return $fields; + } + + /** + * Merge the attributes of merged resources into an array of attributes. + * + * @param array $attributes + * + * @return array + */ + protected function mergeAttributes(array $attributes) + { + foreach ($this->merged as $resource) { + $attributes = array_replace_recursive($attributes, $resource->getAttributes()); + } + + return $attributes; + } + + /** + * Get the resource relationships. + * + * @return \Tobscure\JsonApi\Relationship[] + */ + public function getRelationships() + { + $relationships = $this->buildRelationships(); + + return $this->filterFields($relationships); + } + + /** + * Get the resource relationships without considering requested ones. + * + * @return \Tobscure\JsonApi\Relationship[] + */ + public function getUnfilteredRelationships() + { + return $this->buildRelationships(); + } + + /** + * Get the resource relationships as an array. + * + * @return array + */ + public function getRelationshipsAsArray() + { + $relationships = $this->getRelationships(); + + $relationships = $this->convertRelationshipsToArray($relationships); + + return $this->mergeRelationships($relationships); + } + + /** + * Get an array of built relationships. + * + * @return \Tobscure\JsonApi\Relationship[] + */ + protected function buildRelationships() + { + if (isset($this->relationships)) { + return $this->relationships; + } + + $paths = Util::parseRelationshipPaths($this->includes); + + $relationships = []; + + foreach ($paths as $name => $nested) { + $relationship = $this->serializer->getRelationship($this->data, $name); + + if ($relationship) { + $relationshipData = $relationship->getData(); + if ($relationshipData instanceof ElementInterface) { + $relationshipData->with($nested)->fields($this->fields); + } + + $relationships[$name] = $relationship; + } + } + + return $this->relationships = $relationships; + } + + /** + * Merge the relationships of merged resources into an array of + * relationships. + * + * @param array $relationships + * + * @return array + */ + protected function mergeRelationships(array $relationships) + { + foreach ($this->merged as $resource) { + $relationships = array_replace_recursive($relationships, $resource->getRelationshipsAsArray()); + } + + return $relationships; + } + + /** + * Convert the given array of Relationship objects into an array. + * + * @param \Tobscure\JsonApi\Relationship[] $relationships + * + * @return array + */ + protected function convertRelationshipsToArray(array $relationships) + { + return array_map(function (Relationship $relationship) { + return $relationship->toArray(); + }, $relationships); + } + + /** + * Merge a resource into this one. + * + * @param \Tobscure\JsonApi\Resource $resource + * + * @return void + */ + public function merge(Resource $resource) + { + $this->merged[] = $resource; + } + + /** + * {@inheritdoc} + */ + public function with($relationships) + { + $this->includes = array_unique(array_merge($this->includes, (array) $relationships)); + + $this->relationships = null; + + return $this; + } + + /** + * {@inheritdoc} + */ + public function fields($fields) + { + $this->fields = $fields; + + return $this; + } + + /** + * @return mixed + */ + public function getData() + { + return $this->data; + } + + /** + * @param mixed $data + * + * @return void + */ + public function setData($data) + { + $this->data = $data; + } + + /** + * @return \Tobscure\JsonApi\SerializerInterface + */ + public function getSerializer() + { + return $this->serializer; + } + + /** + * @param \Tobscure\JsonApi\SerializerInterface $serializer + * + * @return void + */ + public function setSerializer(SerializerInterface $serializer) + { + $this->serializer = $serializer; + } +} diff --git a/src/Tobscure/JsonApi/SerializerInterface.php b/src/Tobscure/JsonApi/SerializerInterface.php new file mode 100644 index 0000000..96730fa --- /dev/null +++ b/src/Tobscure/JsonApi/SerializerInterface.php @@ -0,0 +1,71 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tobscure\JsonApi; + +interface SerializerInterface +{ + /** + * Get the type. + * + * @param mixed $model + * + * @return string + */ + public function getType($model); + + /** + * Get the id. + * + * @param mixed $model + * + * @return string + */ + public function getId($model); + + /** + * Get the attributes array. + * + * @param mixed $model + * @param array|null $fields + * + * @return array + */ + public function getAttributes($model, array $fields = null); + + /** + * Get the links array. + * + * @param mixed $model + * + * @return array + */ + public function getLinks($model); + + /** + * Get the meta. + * + * @param mixed $model + * + * @return array + */ + public function getMeta($model); + + /** + * Get a relationship. + * + * @param mixed $model + * @param string $name + * + * @return \Tobscure\JsonApi\Relationship|null + */ + public function getRelationship($model, $name); +} diff --git a/src/Tobscure/JsonApi/Util.php b/src/Tobscure/JsonApi/Util.php new file mode 100644 index 0000000..cac77a3 --- /dev/null +++ b/src/Tobscure/JsonApi/Util.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tobscure\JsonApi; + +class Util +{ + /** + * Parse relationship paths. + * + * Given a flat array of relationship paths like: + * + * ['user', 'user.employer', 'user.employer.country', 'comments'] + * + * create a nested array of relationship paths one-level deep that can + * be passed on to other serializers: + * + * ['user' => ['employer', 'employer.country'], 'comments' => []] + * + * @param array $paths + * + * @return array + */ + public static function parseRelationshipPaths(array $paths) + { + $tree = []; + + foreach ($paths as $path) { + list($primary, $nested) = array_pad(explode('.', $path, 2), 2, null); + + if (! isset($tree[$primary])) { + $tree[$primary] = []; + } + + if ($nested) { + $tree[$primary][] = $nested; + } + } + + return $tree; + } +} diff --git a/src/Validators/FishImageValidator.php b/src/Validators/FishImageValidator.php new file mode 100644 index 0000000..36b5fe3 --- /dev/null +++ b/src/Validators/FishImageValidator.php @@ -0,0 +1,12 @@ + 'required|mimes:jpeg,png,bmp,gif|max:2048', + ]; +} diff --git a/src/Validators/FishValidator.php b/src/Validators/FishValidator.php new file mode 100644 index 0000000..88ccf39 --- /dev/null +++ b/src/Validators/FishValidator.php @@ -0,0 +1,12 @@ + 'required|string|min:3', + ]; +} diff --git a/src/Validators/RoundValidator.php b/src/Validators/RoundValidator.php new file mode 100644 index 0000000..33456d7 --- /dev/null +++ b/src/Validators/RoundValidator.php @@ -0,0 +1,14 @@ + 'required|string', + 'starts_at' => 'nullable|date', + 'ends_at' => 'required|date', + ]; +}