first commit

这个提交包含在:
2026-07-31 00:13:44 +08:00
当前提交 21b5eaa7a0
修改 90 个文件,包含 5474 行新增0 行删除
+21
查看文件
@@ -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.
+53
查看文件
@@ -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.
+53
查看文件
@@ -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/"
}
}
}
+116
查看文件
@@ -0,0 +1,116 @@
<?php
namespace XXB\CatchTheFish;
use XXB\CatchTheFish\Controllers;
use XXB\CatchTheFish\Api\Resource\FishResource;
use XXB\CatchTheFish\Api\Resource\RoundResource;
use Flarum\Api\Context;
use Flarum\Api\Endpoint;
use Flarum\Api\Resource;
use Flarum\Api\Schema;
use Flarum\Discussion\Discussion;
use Flarum\Extend;
use Flarum\Post\Post;
use Flarum\Settings\SettingsRepositoryInterface;
use Flarum\User\User;
return [
(new Extend\Frontend('forum'))
->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),
];
第三方依赖
+2
查看文件
文件差异因一行或多行过长而隐藏
第三方依赖
+1
查看文件
文件差异因一行或多行过长而隐藏
第三方依赖
+2
查看文件
文件差异因一行或多行过长而隐藏
第三方依赖
+1
查看文件
文件差异因一行或多行过长而隐藏
+193
查看文件
@@ -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;
});
}
+10
查看文件
@@ -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);
}
+21
查看文件
@@ -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')));
}
});
}
+43
查看文件
@@ -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);
+111
查看文件
@@ -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);
+17
查看文件
@@ -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);
@@ -0,0 +1,19 @@
<?php
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Schema\Builder;
return [
'up' => 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');
},
];
@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Schema\Builder;
return [
'up' => 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');
},
];
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Schema\Builder;
return [
'up' => 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');
},
];
@@ -0,0 +1,39 @@
<?php
use XXB\CatchTheFish\Ranking;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Schema\Builder;
return [
'up' => 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
},
];
二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 97 KiB

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 147 KiB

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 128 KiB

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 170 KiB

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 124 KiB

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 44 KiB

+29
查看文件
@@ -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;
}
}
+255
查看文件
@@ -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);
}
}
+183
查看文件
@@ -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
+183
查看文件
@@ -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: 此轮次已经结束。
+83
查看文件
@@ -0,0 +1,83 @@
<?php
namespace XXB\CatchTheFish\Access;
use Carbon\Carbon;
use XXB\CatchTheFish\Fish;
use Flarum\Foundation\ValidationException;
use Flarum\Locale\Translator;
use Flarum\User\Access\AbstractPolicy;
use Flarum\User\User;
class FishPolicy extends AbstractPolicy
{
const TRANSLATION_PREFIX = 'xxb-catch-the-fish.api.';
public function create(User $actor)
{
return $actor->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'));
}
}
+73
查看文件
@@ -0,0 +1,73 @@
<?php
namespace XXB\CatchTheFish\Access;
use Carbon\Carbon;
use XXB\CatchTheFish\Round;
use Flarum\Foundation\ValidationException;
use Flarum\Locale\Translator;
use Flarum\User\Access\AbstractPolicy;
use Flarum\User\User;
class RoundPolicy extends AbstractPolicy
{
const TRANSLATION_PREFIX = 'xxb-catch-the-fish.api.';
public function list(User $actor)
{
return $actor->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');
}
}
+79
查看文件
@@ -0,0 +1,79 @@
<?php
namespace XXB\CatchTheFish\Api\Resource;
use DateTimeInterface;
use XXB\CatchTheFish\Fish;
use Flarum\Api\Context;
use Flarum\Api\Resource\AbstractDatabaseResource;
use Flarum\Api\Schema;
use Flarum\Foundation\ValidationException;
class FishResource extends AbstractDatabaseResource
{
public function type(): string
{
return 'catchthefish-fishes';
}
public function model(): string
{
return Fish::class;
}
public function endpoints(): array
{
return [];
}
public function fields(): array
{
return [
Schema\Str::make('name'),
Schema\Str::make('image_url')
->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;
}
}
+50
查看文件
@@ -0,0 +1,50 @@
<?php
namespace XXB\CatchTheFish\Api\Resource;
use DateTimeInterface;
use XXB\CatchTheFish\Round;
use Flarum\Api\Context;
use Flarum\Api\Endpoint;
use Flarum\Api\Resource\AbstractDatabaseResource;
use Flarum\Api\Schema;
class RoundResource extends AbstractDatabaseResource
{
public function type(): string
{
return 'catchthefish-rounds';
}
public function model(): string
{
return Round::class;
}
public function endpoints(): array
{
return [];
}
public function fields(): array
{
return [
Schema\Str::make('name'),
Schema\Str::make('starts_at')
->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;
}
}
+20
查看文件
@@ -0,0 +1,20 @@
<?php
namespace XXB\CatchTheFish;
use Carbon\Carbon;
use Flarum\Database\AbstractModel;
use Illuminate\Database\Eloquent\Builder;
class ConfigureBasketRelationship
{
public function __invoke(AbstractModel $model)
{
return $model->hasMany(Fish::class, 'user_id_last_catch')
->whereHas('round', function (Builder $query) {
$query->activeRound();
})
->where('placement_valid_since', '>', Carbon::now())
->orderBy('name');
}
}
+25
查看文件
@@ -0,0 +1,25 @@
<?php
namespace XXB\CatchTheFish;
use Flarum\Database\AbstractModel;
use Illuminate\Database\Eloquent\Builder;
class ConfigureFishesRelationship
{
protected $foreignKey;
public function __construct(string $foreignKey)
{
$this->foreignKey = $foreignKey;
}
public function __invoke(AbstractModel $model)
{
return $model->hasMany(Fish::class, $this->foreignKey)
->whereHas('round', function (Builder $query) {
$query->activeRound();
})
->activeFish();
}
}
@@ -0,0 +1,7 @@
<?php
namespace XXB\CatchTheFish\Controllers;
abstract class AbstractCreateController extends AbstractShowController
{
}
@@ -0,0 +1,20 @@
<?php
namespace XXB\CatchTheFish\Controllers;
use Laminas\Diactoros\Response\EmptyResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
abstract class AbstractDeleteController implements RequestHandlerInterface
{
public function handle(ServerRequestInterface $request): ResponseInterface
{
$this->delete($request);
return new EmptyResponse(204);
}
abstract protected function delete(ServerRequestInterface $request);
}
@@ -0,0 +1,14 @@
<?php
namespace XXB\CatchTheFish\Controllers;
use Tobscure\JsonApi\Collection;
use Tobscure\JsonApi\SerializerInterface;
abstract class AbstractListController extends AbstractSerializeController
{
protected function createElement($data, SerializerInterface $serializer)
{
return new Collection($data, $serializer);
}
}
@@ -0,0 +1,76 @@
<?php
namespace XXB\CatchTheFish\Controllers;
use Illuminate\Support\Arr;
use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Tobscure\JsonApi\Document;
use Tobscure\JsonApi\Parameters;
use Tobscure\JsonApi\SerializerInterface;
abstract class AbstractSerializeController implements RequestHandlerInterface
{
public $serializer;
public $include = [];
public $optionalInclude = [];
public $maxLimit = 50;
public $limit = 20;
public function handle(ServerRequestInterface $request): ResponseInterface
{
$document = new Document();
$data = $this->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);
}
}
@@ -0,0 +1,14 @@
<?php
namespace XXB\CatchTheFish\Controllers;
use Tobscure\JsonApi\Resource;
use Tobscure\JsonApi\SerializerInterface;
abstract class AbstractShowController extends AbstractSerializeController
{
protected function createElement($data, SerializerInterface $serializer)
{
return new Resource($data, $serializer);
}
}
+39
查看文件
@@ -0,0 +1,39 @@
<?php
namespace XXB\CatchTheFish\Controllers;
use XXB\CatchTheFish\Repositories\FishRepository;
use XXB\CatchTheFish\Repositories\PlacementRepository;
use XXB\CatchTheFish\Serializers\FishSerializer;
use XXB\CatchTheFish\Controllers\AbstractShowController;
use Flarum\Http\RequestUtil;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class FishCatchController extends AbstractShowController
{
public $serializer = FishSerializer::class;
public $include = [
'round.myRanking',
];
protected $fishes;
protected $placement;
public function __construct(FishRepository $fishes, PlacementRepository $placement)
{
$this->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());
}
}
+30
查看文件
@@ -0,0 +1,30 @@
<?php
namespace XXB\CatchTheFish\Controllers;
use XXB\CatchTheFish\Repositories\FishRepository;
use XXB\CatchTheFish\Controllers\AbstractDeleteController;
use Flarum\Http\RequestUtil;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
class FishDeleteController extends AbstractDeleteController
{
protected $fishes;
public function __construct(FishRepository $fishes)
{
$this->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);
}
}
@@ -0,0 +1,42 @@
<?php
namespace XXB\CatchTheFish\Controllers;
use XXB\CatchTheFish\Repositories\FishRepository;
use XXB\CatchTheFish\Repositories\RoundRepository;
use XXB\CatchTheFish\Serializers\FishSerializer;
use XXB\CatchTheFish\Controllers\AbstractListController;
use Flarum\Http\RequestUtil;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class FishImageBulkController extends AbstractListController
{
public $serializer = FishSerializer::class;
protected $rounds;
protected $fishes;
public function __construct(RoundRepository $rounds, FishRepository $fishes)
{
$this->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);
}
}
+43
查看文件
@@ -0,0 +1,43 @@
<?php
namespace XXB\CatchTheFish\Controllers;
use XXB\CatchTheFish\Repositories\FishRepository;
use XXB\CatchTheFish\Serializers\FishSerializer;
use XXB\CatchTheFish\Controllers\AbstractShowController;
use Flarum\Foundation\ValidationException;
use Flarum\Http\RequestUtil;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class FishImageController extends AbstractShowController
{
public $serializer = FishSerializer::class;
protected $fishes;
public function __construct(FishRepository $fishes)
{
$this->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);
}
}
+44
查看文件
@@ -0,0 +1,44 @@
<?php
namespace XXB\CatchTheFish\Controllers;
use XXB\CatchTheFish\Repositories\FishRepository;
use XXB\CatchTheFish\Repositories\RoundRepository;
use XXB\CatchTheFish\Serializers\FishSerializer;
use XXB\CatchTheFish\Controllers\AbstractListController;
use Flarum\Http\RequestUtil;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class FishIndexController extends AbstractListController
{
public $serializer = FishSerializer::class;
public $include = [
'lastUserPlacement',
'lastUserNaming',
'placement',
'placement.discussion',
];
protected $rounds;
protected $fishes;
public function __construct(RoundRepository $rounds, FishRepository $fishes)
{
$this->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));
}
}
+35
查看文件
@@ -0,0 +1,35 @@
<?php
namespace XXB\CatchTheFish\Controllers;
use XXB\CatchTheFish\Repositories\FishRepository;
use XXB\CatchTheFish\Repositories\PlacementRepository;
use XXB\CatchTheFish\Serializers\FishSerializer;
use XXB\CatchTheFish\Controllers\AbstractShowController;
use Flarum\Http\RequestUtil;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class FishPlaceController extends AbstractShowController
{
public $serializer = FishSerializer::class;
protected $fishes;
protected $placement;
public function __construct(FishRepository $fishes, PlacementRepository $placement)
{
$this->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());
}
}
+39
查看文件
@@ -0,0 +1,39 @@
<?php
namespace XXB\CatchTheFish\Controllers;
use XXB\CatchTheFish\Repositories\FishRepository;
use XXB\CatchTheFish\Repositories\RoundRepository;
use XXB\CatchTheFish\Serializers\FishSerializer;
use XXB\CatchTheFish\Controllers\AbstractCreateController;
use Flarum\Http\RequestUtil;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class FishStoreController extends AbstractCreateController
{
public $serializer = FishSerializer::class;
protected $rounds;
protected $fishes;
public function __construct(RoundRepository $rounds, FishRepository $fishes)
{
$this->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);
}
}
+36
查看文件
@@ -0,0 +1,36 @@
<?php
namespace XXB\CatchTheFish\Controllers;
use XXB\CatchTheFish\Repositories\FishRepository;
use XXB\CatchTheFish\Serializers\FishSerializer;
use XXB\CatchTheFish\Controllers\AbstractShowController;
use Flarum\Http\RequestUtil;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class FishUpdateController extends AbstractShowController
{
public $serializer = FishSerializer::class;
protected $fishes;
public function __construct(FishRepository $fishes)
{
$this->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);
}
}
@@ -0,0 +1,41 @@
<?php
namespace XXB\CatchTheFish\Controllers;
use XXB\CatchTheFish\Repositories\RankingRepository;
use XXB\CatchTheFish\Repositories\RoundRepository;
use XXB\CatchTheFish\Serializers\RankingSerializer;
use XXB\CatchTheFish\Controllers\AbstractListController;
use Flarum\Http\RequestUtil;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class RankingIndexController extends AbstractListController
{
public $serializer = RankingSerializer::class;
public $include = [
'user',
];
protected $rounds;
protected $rankings;
public function __construct(RoundRepository $rounds, RankingRepository $rankings)
{
$this->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);
}
}
+30
查看文件
@@ -0,0 +1,30 @@
<?php
namespace XXB\CatchTheFish\Controllers;
use XXB\CatchTheFish\Repositories\RoundRepository;
use XXB\CatchTheFish\Controllers\AbstractDeleteController;
use Flarum\Http\RequestUtil;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
class RoundDeleteController extends AbstractDeleteController
{
protected $rounds;
public function __construct(RoundRepository $rounds)
{
$this->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);
}
}
+38
查看文件
@@ -0,0 +1,38 @@
<?php
namespace XXB\CatchTheFish\Controllers;
use XXB\CatchTheFish\Repositories\RoundRepository;
use XXB\CatchTheFish\Round;
use XXB\CatchTheFish\Serializers\RoundSerializer;
use XXB\CatchTheFish\Controllers\AbstractListController;
use Flarum\Http\RequestUtil;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class RoundIndexController extends AbstractListController
{
public $serializer = RoundSerializer::class;
protected $rounds;
public function __construct(RoundRepository $rounds)
{
$this->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));
}
}
+34
查看文件
@@ -0,0 +1,34 @@
<?php
namespace XXB\CatchTheFish\Controllers;
use XXB\CatchTheFish\Repositories\RoundRepository;
use XXB\CatchTheFish\Serializers\RoundSerializer;
use XXB\CatchTheFish\Controllers\AbstractShowController;
use Flarum\Http\RequestUtil;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class RoundShowController extends AbstractShowController
{
public $serializer = RoundSerializer::class;
protected $rounds;
public function __construct(RoundRepository $rounds)
{
$this->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;
}
}
+33
查看文件
@@ -0,0 +1,33 @@
<?php
namespace XXB\CatchTheFish\Controllers;
use XXB\CatchTheFish\Repositories\RoundRepository;
use XXB\CatchTheFish\Round;
use XXB\CatchTheFish\Serializers\RoundSerializer;
use XXB\CatchTheFish\Controllers\AbstractCreateController;
use Flarum\Http\RequestUtil;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class RoundStoreController extends AbstractCreateController
{
public $serializer = RoundSerializer::class;
protected $rounds;
public function __construct(RoundRepository $rounds)
{
$this->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);
}
}
+36
查看文件
@@ -0,0 +1,36 @@
<?php
namespace XXB\CatchTheFish\Controllers;
use XXB\CatchTheFish\Repositories\RoundRepository;
use XXB\CatchTheFish\Serializers\RoundSerializer;
use XXB\CatchTheFish\Controllers\AbstractShowController;
use Flarum\Http\RequestUtil;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class RoundUpdateController extends AbstractShowController
{
public $serializer = RoundSerializer::class;
protected $rounds;
public function __construct(RoundRepository $rounds)
{
$this->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);
}
}
+42
查看文件
@@ -0,0 +1,42 @@
<?php
namespace XXB\CatchTheFish\Extenders;
use Flarum\Extend\ApiController;
use Flarum\Extend\ExtenderInterface;
use Flarum\Extension\Extension;
use Illuminate\Contracts\Container\Container;
/**
* Custom extender that allows calling ApiController::addIncludes on multiple controller classes at once
*/
class ApiControllerIncludes implements ExtenderInterface
{
protected $controllerClasses = [];
protected $addIncludes = [];
public function __construct(array $controllerClasses)
{
$this->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);
}
}
}
+83
查看文件
@@ -0,0 +1,83 @@
<?php
namespace XXB\CatchTheFish;
use Carbon\Carbon;
use Flarum\Database\AbstractModel;
use Flarum\Discussion\Discussion;
use Flarum\Http\UrlGenerator;
use Flarum\Post\Post;
use Flarum\User\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Fish extends AbstractModel
{
protected $table = 'catchthefish_fishes';
protected $casts = [
'placement_valid_since' => '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());
}
}
+25
查看文件
@@ -0,0 +1,25 @@
<?php
namespace XXB\CatchTheFish;
use Flarum\Settings\SettingsRepositoryInterface;
class ForumAttributes
{
protected $settings;
public function __construct(SettingsRepositoryInterface $settings)
{
$this->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',
];
}
}
+27
查看文件
@@ -0,0 +1,27 @@
<?php
namespace XXB\CatchTheFish;
use XXB\CatchTheFish\Repositories\RoundRepository;
use Flarum\Api\Controller\ShowForumController;
use Flarum\Http\RequestUtil;
use Psr\Http\Message\ServerRequestInterface;
class LoadRoundsRelationship
{
protected $repository;
public function __construct(RoundRepository $repository)
{
$this->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'] = [];
}
}
}
+23
查看文件
@@ -0,0 +1,23 @@
<?php
namespace XXB\CatchTheFish\Providers;
use XXB\CatchTheFish\Repositories\FishImageUploader;
use Flarum\Foundation\AbstractServiceProvider;
use Flarum\Foundation\Paths;
use League\Flysystem\Filesystem;
use League\Flysystem\Local\LocalFilesystemAdapter;
class StorageServiceProvider extends AbstractServiceProvider
{
public function register()
{
$this->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');
}
}
+34
查看文件
@@ -0,0 +1,34 @@
<?php
namespace XXB\CatchTheFish;
use Flarum\Database\AbstractModel;
use Flarum\User\User;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Ranking extends AbstractModel
{
protected $table = 'catchthefish_rankings';
protected $casts = [
'catch_count' => '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);
}
}
+59
查看文件
@@ -0,0 +1,59 @@
<?php
namespace XXB\CatchTheFish\Repositories;
use XXB\CatchTheFish\Fish;
use Illuminate\Support\Str;
use Intervention\Image\Constraint;
use Intervention\Image\Image;
use League\Flysystem\Filesystem;
class FishImageUploader
{
protected $assets;
public function __construct(Filesystem $assets)
{
$this->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;
}
}
+202
查看文件
@@ -0,0 +1,202 @@
<?php
namespace XXB\CatchTheFish\Repositories;
use Carbon\Carbon;
use XXB\CatchTheFish\Fish;
use XXB\CatchTheFish\Round;
use XXB\CatchTheFish\Validators\FishImageValidator;
use XXB\CatchTheFish\Validators\FishValidator;
use Flarum\Foundation\Paths;
use Flarum\Locale\Translator;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Arr;
use Intervention\Image\Image;
use Intervention\Image\ImageManager;
use Psr\Http\Message\UploadedFileInterface;
use Symfony\Component\HttpFoundation\File\UploadedFile;
class FishRepository
{
const BASE_IMAGES = [
'pixabay-30828-640.png',
'pixabay-30837-640.png',
'pixabay-33712-640.png',
'pixabay-36828-640.png',
'pixabay-294469-640.png',
'pixabay-1331813-640.png',
];
protected $paths;
protected $validator;
protected $imageValidator;
protected $uploader;
protected $translator;
public function __construct(Paths $paths, FishValidator $validator, FishImageValidator $imageValidator, FishImageUploader $uploader, Translator $translator)
{
$this->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);
}
}
+296
查看文件
@@ -0,0 +1,296 @@
<?php
namespace XXB\CatchTheFish\Repositories;
use Carbon\Carbon;
use XXB\CatchTheFish\Fish;
use Flarum\Discussion\Discussion;
use Flarum\Extension\ExtensionManager;
use Flarum\Foundation\ValidationException;
use Flarum\Locale\Translator;
use Flarum\Post\Post;
use Flarum\Settings\SettingsRepositoryInterface;
use Flarum\User\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
class Placement
{
public $discussionId;
public $postId;
public $userId;
const TRANSLATION_PREFIX = 'xxb-catch-the-fish.api.';
protected static function intSettingWithDefault(string $key, int $default): int
{
$value = resolve(SettingsRepositoryInterface::class)->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;
}
}
+123
查看文件
@@ -0,0 +1,123 @@
<?php
namespace XXB\CatchTheFish\Repositories;
use Carbon\Carbon;
use XXB\CatchTheFish\Fish;
use XXB\CatchTheFish\Ranking;
use XXB\CatchTheFish\Validators\FishValidator;
use Flarum\Foundation\ValidationException;
use Flarum\Locale\Translator;
use Flarum\Settings\SettingsRepositoryInterface;
use Flarum\User\User;
use Illuminate\Support\Arr;
class PlacementRepository
{
protected $settings;
protected $validator;
protected $translator;
public function __construct(SettingsRepositoryInterface $settings, FishValidator $validator, Translator $translator)
{
$this->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;
}
}
+15
查看文件
@@ -0,0 +1,15 @@
<?php
namespace XXB\CatchTheFish\Repositories;
use XXB\CatchTheFish\Round;
class RankingRepository
{
public function all(Round $round)
{
return $round->rankings()
->orderBy('catch_count', 'desc')
->get();
}
}
+93
查看文件
@@ -0,0 +1,93 @@
<?php
namespace XXB\CatchTheFish\Repositories;
use Carbon\Carbon;
use XXB\CatchTheFish\Round;
use XXB\CatchTheFish\Validators\RoundValidator;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Arr;
class RoundRepository
{
protected $validator;
protected $fishRepository;
protected $uploader;
public function __construct(RoundValidator $validator, FishRepository $fishRepository, FishImageUploader $uploader)
{
$this->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();
}
}
+54
查看文件
@@ -0,0 +1,54 @@
<?php
namespace XXB\CatchTheFish;
use Carbon\Carbon;
use Flarum\Database\AbstractModel;
use Flarum\User\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Round extends AbstractModel
{
protected $table = 'catchthefish_rounds';
protected $casts = [
'starts_at' => '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);
});
}
}
+100
查看文件
@@ -0,0 +1,100 @@
<?php
namespace XXB\CatchTheFish\Serializers;
use DateTimeInterface;
use Flarum\Http\RequestUtil;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Relationship;
use Tobscure\JsonApi\Resource;
use Tobscure\JsonApi\SerializerInterface;
abstract class AbstractSerializer implements SerializerInterface
{
protected $type;
protected $actor;
protected $request;
public function setRequest(ServerRequestInterface $request): void
{
$this->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);
}
}
@@ -0,0 +1,17 @@
<?php
namespace XXB\CatchTheFish\Serializers;
class BasicDiscussionSerializer extends AbstractSerializer
{
protected $type = 'discussions';
protected function getDefaultAttributes($discussion): array
{
return [
'title' => $discussion->title,
'slug' => method_exists($discussion, 'slug') ? $discussion->slug() : (string) $discussion->id,
'commentCount' => $discussion->comment_count,
];
}
}
+7
查看文件
@@ -0,0 +1,7 @@
<?php
namespace XXB\CatchTheFish\Serializers;
class BasicUserSerializer extends UserSerializer
{
}
+87
查看文件
@@ -0,0 +1,87 @@
<?php
namespace XXB\CatchTheFish\Serializers;
use XXB\CatchTheFish\Fish;
use Flarum\Foundation\ValidationException;
use Tobscure\JsonApi\Relationship;
use Tobscure\JsonApi\Resource;
class FishSerializer extends AbstractSerializer
{
protected $type = 'catchthefish-fishes';
protected function actorCan($ability, $arguments = [])
{
try {
return $this->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;
}
}
+23
查看文件
@@ -0,0 +1,23 @@
<?php
namespace XXB\CatchTheFish\Serializers;
use Tobscure\JsonApi\Relationship;
class PostSerializer extends AbstractSerializer
{
protected $type = 'posts';
protected function getDefaultAttributes($post): array
{
return [
'number' => $post->number,
'createdAt' => $this->formatDate($post->created_at),
];
}
public function discussion($post): ?Relationship
{
return $this->hasOne($post, BasicDiscussionSerializer::class, 'discussion');
}
}
+23
查看文件
@@ -0,0 +1,23 @@
<?php
namespace XXB\CatchTheFish\Serializers;
use XXB\CatchTheFish\Ranking;
use Tobscure\JsonApi\Relationship;
class RankingSerializer extends AbstractSerializer
{
protected $type = 'catchthefish-rankings';
protected function getDefaultAttributes($ranking): array
{
return [
'catch_count' => $ranking->catch_count,
];
}
public function user(Ranking $ranking): ?Relationship
{
return $this->hasOne($ranking, UserSerializer::class, 'user');
}
}
+36
查看文件
@@ -0,0 +1,36 @@
<?php
namespace XXB\CatchTheFish\Serializers;
use XXB\CatchTheFish\Ranking;
use XXB\CatchTheFish\Round;
use Tobscure\JsonApi\Relationship;
use Tobscure\JsonApi\Resource;
class RoundSerializer extends AbstractSerializer
{
protected $type = 'catchthefish-rounds';
protected function getDefaultAttributes($round): array
{
$ranking = $round->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)));
}
}
+18
查看文件
@@ -0,0 +1,18 @@
<?php
namespace XXB\CatchTheFish\Serializers;
class UserSerializer extends AbstractSerializer
{
protected $type = 'users';
protected function getDefaultAttributes($user): array
{
return [
'username' => $user->username,
'displayName' => $user->display_name ?: $user->username,
'avatarUrl' => $user->avatar_url,
'slug' => method_exists($user, 'slug') ? $user->slug() : (string) $user->id,
];
}
}
+106
查看文件
@@ -0,0 +1,106 @@
<?php
/*
* This file is part of JSON-API.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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;
}
}
+142
查看文件
@@ -0,0 +1,142 @@
<?php
/*
* This file is part of JSON-API.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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);
}
}
+231
查看文件
@@ -0,0 +1,231 @@
<?php
/*
* This file is part of JSON-API.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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();
}
}
+54
查看文件
@@ -0,0 +1,54 @@
<?php
/*
* This file is part of JSON-API.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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);
}
+58
查看文件
@@ -0,0 +1,58 @@
<?php
/*
* This file is part of JSON-API.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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;
}
}
@@ -0,0 +1,42 @@
<?php
/*
* This file is part of JSON-API.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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;
}
}
+135
查看文件
@@ -0,0 +1,135 @@
<?php
/*
* This file is part of JSON-API.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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 : ''));
}
}
+61
查看文件
@@ -0,0 +1,61 @@
<?php
/*
* This file is part of JSON-API.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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;
}
}
+220
查看文件
@@ -0,0 +1,220 @@
<?php
/*
* This file is part of JSON-API.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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] : '';
}
}
+83
查看文件
@@ -0,0 +1,83 @@
<?php
/*
* This file is part of JSON-API.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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;
}
}
+406
查看文件
@@ -0,0 +1,406 @@
<?php
/*
* This file is part of JSON-API.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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;
}
}
@@ -0,0 +1,71 @@
<?php
/*
* This file is part of JSON-API.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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);
}
+50
查看文件
@@ -0,0 +1,50 @@
<?php
/*
* This file is part of JSON-API.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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;
}
}
+12
查看文件
@@ -0,0 +1,12 @@
<?php
namespace XXB\CatchTheFish\Validators;
use Flarum\Foundation\AbstractValidator;
class FishImageValidator extends AbstractValidator
{
protected array $rules = [
'image' => 'required|mimes:jpeg,png,bmp,gif|max:2048',
];
}
+12
查看文件
@@ -0,0 +1,12 @@
<?php
namespace XXB\CatchTheFish\Validators;
use Flarum\Foundation\AbstractValidator;
class FishValidator extends AbstractValidator
{
protected array $rules = [
'name' => 'required|string|min:3',
];
}
+14
查看文件
@@ -0,0 +1,14 @@
<?php
namespace XXB\CatchTheFish\Validators;
use Flarum\Foundation\AbstractValidator;
class RoundValidator extends AbstractValidator
{
protected array $rules = [
'name' => 'required|string',
'starts_at' => 'nullable|date',
'ends_at' => 'required|date',
];
}