Merge pull request #161 from shikorism/develop

Release 20190321.1905
This commit is contained in:
shibafu 2019-03-21 19:08:56 +09:00 committed by GitHub
commit 5153de54d2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
56 changed files with 2699 additions and 116 deletions

View File

@ -57,6 +57,12 @@ jobs:
- store_test_results: - store_test_results:
path: /tmp/php-cs-fixer path: /tmp/php-cs-fixer
# Run stylelint
- run:
name: stylelint
command: yarn run stylelint
when: always
# Run unit test # Run unit test
- run: - run:
command: | command: |

1
.gitignore vendored
View File

@ -8,6 +8,7 @@
/storage/*.key /storage/*.key
/vendor /vendor
/.idea /.idea
/.vscode
/.vagrant /.vagrant
Homestead.json Homestead.json
Homestead.yaml Homestead.yaml

View File

@ -1,3 +1,5 @@
FROM node:10-jessie as node
FROM php:7.1-apache FROM php:7.1-apache
ENV APACHE_DOCUMENT_ROOT /var/www/html/public ENV APACHE_DOCUMENT_ROOT /var/www/html/public
@ -15,6 +17,16 @@ RUN apt-get update \
COPY dist/bin /usr/local/bin/ COPY dist/bin /usr/local/bin/
COPY dist/php.d /usr/local/etc/php/php.d/ COPY dist/php.d /usr/local/etc/php/php.d/
COPY --from=node /usr/local/bin/node /usr/local/bin/
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
COPY --from=node /opt/yarn-* /opt/yarn
RUN ln -s /opt/yarn/bin/yarn /usr/local/bin/yarn \
&& ln -s ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
&& ln -s ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
ENTRYPOINT ["tissue-entrypoint.sh"] ENTRYPOINT ["tissue-entrypoint.sh"]
CMD ["apache2-foreground"] CMD ["apache2-foreground"]

View File

@ -8,7 +8,7 @@ a.k.a. shikorism.net
## 構成 ## 構成
- Laravel 5.5 - Laravel 5.5
- Bootstrap 4.2.1 - Bootstrap 4.3.1
## 実行環境 ## 実行環境
@ -33,10 +33,11 @@ docker-compose build
docker-compose up -d docker-compose up -d
``` ```
4. Composer を使い必要なライブラリをインストールします。 4. Composer と yarn を使い必要なライブラリをインストールします。
``` ```
docker-compose exec web composer install docker-compose exec web composer install
docker-compose exec web yarn install
``` ```
5. 暗号化キーの作成と、データベースのマイグレーションを行います。 5. 暗号化キーの作成と、データベースのマイグレーションを行います。
@ -52,7 +53,14 @@ docker-compose exec web php artisan migrate
docker-compose exec web chown -R www-data /var/www/html docker-compose exec web chown -R www-data /var/www/html
``` ```
7. 最後に `.env` を読み込み直すために起動し直します。 7. アセットをビルドします。
```
docker-compose exec web yarn dev
```
8. 最後に `.env` を読み込み直すために起動し直します。
``` ```
docker-compose up -d docker-compose up -d
@ -68,6 +76,16 @@ docker-compose -f docker-compose.yml -f docker-compose.debug.yml up -d
で起動することにより、DB のポート`5432`を開放してホストマシンから接続できるようになります。 で起動することにより、DB のポート`5432`を開放してホストマシンから接続できるようになります。
## アセットのリアルタイムビルド
`yarn watch`を使うとソースファイルを監視して差分があると差分ビルドしてくれます。フロント開発時は活用しましょう。
```
docker-compose run --rm web yarn watch
```
もしファイル変更時に更新されない場合は`yarn watch-poll`を試してみてください。
現在Docker環境でのHMRはサポートしてません。Docker外ならおそらく動くでしょう。
その他詳しくはlaravel-mixのドキュメントなどを当たってください。
## 環境構築上の諸注意 ## 環境構築上の諸注意
- 初版時点では、DB サーバとして PostgreSQL を使うよう .env ファイルを設定するくらいです。 - 初版時点では、DB サーバとして PostgreSQL を使うよう .env ファイルを設定するくらいです。

View File

@ -0,0 +1,61 @@
<?php
namespace App\Console\Commands;
use App\User;
use Illuminate\Console\Command;
class DemoteUser extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'tissue:user:demote {username}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Demote admin to user';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$user = User::where('name', $this->argument('username'))->first();
if ($user === null) {
$this->error('No user with such username');
return 1;
}
if (!$user->is_admin) {
$this->info('@' . $user->name . ' is already an user.');
return 0;
}
$user->is_admin = false;
if ($user->save()) {
$this->info('@' . $user->name . ' is an user now.');
} else {
$this->error('Something happened.');
}
}
}

View File

@ -0,0 +1,61 @@
<?php
namespace App\Console\Commands;
use App\User;
use Illuminate\Console\Command;
class PromoteUser extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'tissue:user:promote {username}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Promote user to admin';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$user = User::where('name', $this->argument('username'))->first();
if ($user === null) {
$this->error('No user with such username');
return 1;
}
if ($user->is_admin) {
$this->info('@' . $user->name . ' is already an administrator.');
return 0;
}
$user->is_admin = true;
if ($user->save()) {
$this->info('@' . $user->name . ' is an administrator now.');
} else {
$this->error('Something happened.');
}
}
}

View File

@ -2,6 +2,8 @@
namespace App\Console; namespace App\Console;
use App\Console\Commands\DemoteUser;
use App\Console\Commands\PromoteUser;
use Illuminate\Console\Scheduling\Schedule; use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel; use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
@ -35,6 +37,8 @@ class Kernel extends ConsoleKernel
*/ */
protected function commands() protected function commands()
{ {
$this->load(__DIR__.'/Commands');
require base_path('routes/console.php'); require base_path('routes/console.php');
} }
} }

View File

@ -0,0 +1,14 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class DashboardController extends Controller
{
public function index()
{
return view('admin.dashboard');
}
}

View File

@ -0,0 +1,75 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\AdminInfoStoreRequest;
use App\Information;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
class InfoController extends Controller
{
public function index()
{
$informations = Information::query()
->select('id', 'category', 'pinned', 'title', 'created_at')
->orderByDesc('pinned')
->orderByDesc('created_at')
->paginate(20);
return view('admin.info.index')->with([
'informations' => $informations,
'categories' => Information::CATEGORIES
]);
}
public function create()
{
return view('admin.info.create')->with([
'categories' => Information::CATEGORIES
]);
}
public function store(AdminInfoStoreRequest $request)
{
$inputs = $request->all();
if (!$request->has('pinned')) {
$inputs['pinned'] = false;
}
$info = Information::create($inputs);
return redirect()->route('admin.info.edit', ['info' => $info])->with('status', 'お知らせを更新しました。');
}
public function edit($id)
{
$information = Information::findOrFail($id);
return view('admin.info.edit')->with([
'info' => $information,
'categories' => Information::CATEGORIES
]);
}
public function update(AdminInfoStoreRequest $request, Information $info)
{
$inputs = $request->all();
if (!$request->has('pinned')) {
$inputs['pinned'] = false;
}
$info->fill($inputs)->save();
return redirect()->route('admin.info.edit', ['info' => $info])->with('status', 'お知らせを更新しました。');
}
public function destroy(Information $info)
{
$info->delete();
return redirect()->route('admin.info')->with('status', 'お知らせを削除しました。');
}
}

View File

@ -30,9 +30,6 @@ class EjaculationController extends Controller
public function store(Request $request) public function store(Request $request)
{ {
$inputs = $request->all(); $inputs = $request->all();
if ($request->has('note')) {
$inputs['note'] = str_replace(["\r\n", "\r"], "\n", $inputs['note']);
}
$validator = Validator::make($inputs, [ $validator = Validator::make($inputs, [
'date' => 'required|date_format:Y/m/d', 'date' => 'required|date_format:Y/m/d',
@ -113,9 +110,6 @@ class EjaculationController extends Controller
$ejaculation = Ejaculation::findOrFail($id); $ejaculation = Ejaculation::findOrFail($id);
$inputs = $request->all(); $inputs = $request->all();
if ($request->has('note')) {
$inputs['note'] = str_replace(["\r\n", "\r"], "\n", $inputs['note']);
}
$validator = Validator::make($inputs, [ $validator = Validator::make($inputs, [
'date' => 'required|date_format:Y/m/d', 'date' => 'required|date_format:Y/m/d',

View File

@ -35,6 +35,7 @@ class Kernel extends HttpKernel
\Illuminate\View\Middleware\ShareErrorsFromSession::class, \Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::class, \App\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class, \Illuminate\Routing\Middleware\SubstituteBindings::class,
\App\Http\Middleware\NormalizeLineEnding::class,
], ],
'api' => [ 'api' => [

View File

@ -0,0 +1,30 @@
<?php
namespace App\Http\Middleware;
use Closure;
/**
* リクエスト内の改行コードを正規化する。
* @package App\Http\Middleware
*/
class NormalizeLineEnding
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle($request, Closure $next)
{
$newInput = [];
foreach ($request->input() as $key => $value) {
$newInput[$key] = str_replace(["\r\n", "\r"], "\n", $value);
}
$request->replace($newInput);
return $next($request);
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Http\Requests;
use App\Information;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class AdminInfoStoreRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize()
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return [
'category' => ['required', Rule::in(array_keys(Information::CATEGORIES))],
'pinned' => 'nullable|boolean',
'title' => 'required|string|max:255',
'content' => 'required|string|max:10000'
];
}
}

View File

@ -16,5 +16,9 @@ class Information extends Model
3 => ['label' => 'メンテナンス', 'class' => 'badge-warning'] 3 => ['label' => 'メンテナンス', 'class' => 'badge-warning']
]; ];
protected $fillable = [
'category', 'pinned', 'title', 'content'
];
protected $dates = ['deleted_at']; protected $dates = ['deleted_at'];
} }

View File

@ -26,6 +26,17 @@ class DLsiteResolver implements Resolver
$res = $this->client->get($url); $res = $this->client->get($url);
if ($res->getStatusCode() === 200) { if ($res->getStatusCode() === 200) {
$metadata = $this->ogpResolver->parse($res->getBody()); $metadata = $this->ogpResolver->parse($res->getBody());
// 抽出
preg_match('~\[(.+)\] \| DLsite$~', $metadata->title, $match);
$maker = $match[1];
// 余分な文を消す
$metadata->title = trim(preg_replace('~ \[.+\] \| DLsite$~', '', $metadata->title));
$metadata->description = trim(preg_replace('~「DLsite.+」は同人誌・同人ゲーム・同人音声のダウンロードショップ。お気に入りの作品をすぐダウンロードできてすぐ楽しめる毎日更新しているのであなたが探している作品にきっと出会えます。国内最大級の二次元総合ダウンロードショップ「DLsite」$~', '', $metadata->description));
// 整形
$metadata->description = 'サークル: ' . $maker . PHP_EOL . $metadata->description;
$metadata->image = str_replace('img_sam.jpg', 'img_main.jpg', $metadata->image); $metadata->image = str_replace('img_sam.jpg', 'img_main.jpg', $metadata->image);
return $metadata; return $metadata;

View File

@ -0,0 +1,44 @@
<?php
namespace App\MetadataResolver;
use GuzzleHttp\Client;
class FC2ContentsResolver implements Resolver
{
/**
* @var Client
*/
private $client;
/**
* @var OGPResolver
*/
private $ogpResolver;
public function __construct(Client $client, OGPResolver $ogpResolver)
{
$this->client = $client;
$this->ogpResolver = $ogpResolver;
}
public function resolve(string $url): Metadata
{
$res = $this->client->get($url);
if ($res->getStatusCode() === 200) {
$metadata = $this->ogpResolver->parse($res->getBody());
$dom = new \DOMDocument();
@$dom->loadHTML(mb_convert_encoding($res->getBody(), 'HTML-ENTITIES', 'UTF-8'));
$xpath = new \DOMXPath($dom);
$thumbnailNode = $xpath->query('//*[@class="main_thum_img"]/a')->item(0);
if ($thumbnailNode) {
$metadata->image = preg_replace('~^http:~', 'https:', $thumbnailNode->getAttribute('href'));
}
return $metadata;
} else {
throw new \RuntimeException("{$res->getStatusCode()}: $url");
}
}
}

View File

@ -27,6 +27,7 @@ class FanzaResolver implements Resolver
if ($res->getStatusCode() === 200) { if ($res->getStatusCode() === 200) {
$metadata = $this->ogpResolver->parse($res->getBody()); $metadata = $this->ogpResolver->parse($res->getBody());
$metadata->image = preg_replace("~(pr|ps)\.jpg$~", 'pl.jpg', $metadata->image); $metadata->image = preg_replace("~(pr|ps)\.jpg$~", 'pl.jpg', $metadata->image);
$metadata->description = str_replace('<>', '', $metadata->description);
return $metadata; return $metadata;
} else { } else {

View File

@ -30,11 +30,40 @@ class MelonbooksResolver implements Resolver
if ($res->getStatusCode() === 200) { if ($res->getStatusCode() === 200) {
$metadata = $this->ogpResolver->parse($res->getBody()); $metadata = $this->ogpResolver->parse($res->getBody());
$dom = new \DOMDocument();
@$dom->loadHTML(mb_convert_encoding($res->getBody(), 'HTML-ENTITIES', 'UTF-8'));
$xpath = new \DOMXPath($dom);
$descriptionNodelist = $xpath->query('//div[@id="description"]//p');
$specialDescriptionNodelist = $xpath->query('//div[@id="special_description"]//p');
// censoredフラグの除去 // censoredフラグの除去
if (mb_strpos($metadata->image, '&c=1') !== false) { if (mb_strpos($metadata->image, '&c=1') !== false) {
$metadata->image = preg_replace('/&c=1/u', '', $metadata->image); $metadata->image = preg_replace('/&c=1/u', '', $metadata->image);
} }
// 抽出
preg_match('~^(.+)(.+))の通販・購入はメロンブックス$~', $metadata->title, $match);
$title = $match[1];
$maker = $match[2];
// 整形
$description = 'サークル: ' . $maker . "\n";
if ($specialDescriptionNodelist->length !== 0) {
$description .= trim(str_replace('<br>', "\n", $specialDescriptionNodelist->item(0)->nodeValue)) . "\n";
if ($specialDescriptionNodelist->length === 2) {
$description .= "\n";
$description .= trim(str_replace('<br>', "\n", $specialDescriptionNodelist->item(1)->nodeValue)) . "\n";
}
}
if ($descriptionNodelist->length !== 0) {
$description .= trim(str_replace('<br>', "\n", $descriptionNodelist->item(0)->nodeValue));
}
$metadata->title = $title;
$metadata->description = trim($description);
return $metadata; return $metadata;
} else { } else {
throw new \RuntimeException("{$res->getStatusCode()}: $url"); throw new \RuntimeException("{$res->getStatusCode()}: $url");

View File

@ -16,6 +16,7 @@ class MetadataResolver implements Resolver
'~ec\.toranoana\.jp/tora_r/ec/item/.*~' => ToranoanaResolver::class, '~ec\.toranoana\.jp/tora_r/ec/item/.*~' => ToranoanaResolver::class,
'~iwara\.tv/videos/.*~' => IwaraResolver::class, '~iwara\.tv/videos/.*~' => IwaraResolver::class,
'~www\.dlsite\.com/.*/work/=/product_id/..\d+\.html~' => DLsiteResolver::class, '~www\.dlsite\.com/.*/work/=/product_id/..\d+\.html~' => DLsiteResolver::class,
'~dlsite\.jp/mawtw/..\d+~' => DLsiteResolver::class,
'~www\.pixiv\.net/member_illust\.php\?illust_id=\d+~' => PixivResolver::class, '~www\.pixiv\.net/member_illust\.php\?illust_id=\d+~' => PixivResolver::class,
'~fantia\.jp/posts/\d+~' => FantiaResolver::class, '~fantia\.jp/posts/\d+~' => FantiaResolver::class,
'~dmm\.co\.jp/~' => FanzaResolver::class, '~dmm\.co\.jp/~' => FanzaResolver::class,
@ -23,6 +24,8 @@ class MetadataResolver implements Resolver
'~www\.deviantart\.com/.*/art/.*~' => DeviantArtResolver::class, '~www\.deviantart\.com/.*/art/.*~' => DeviantArtResolver::class,
'~\.syosetu\.com/n\d+[a-z]{2,}~' => NarouResolver::class, '~\.syosetu\.com/n\d+[a-z]{2,}~' => NarouResolver::class,
'~ci-en\.jp/creator/\d+/article/\d+~' => CienResolver::class, '~ci-en\.jp/creator/\d+/article/\d+~' => CienResolver::class,
'~www\.plurk\.com\/p\/.*~' => PlurkResolver::class,
'~(adult\.)?contents\.fc2\.com\/article_search\.php\?id=\d+~' => FC2ContentsResolver::class,
]; ];
public $mimeTypes = [ public $mimeTypes = [

View File

@ -28,11 +28,11 @@ class PatreonResolver implements Resolver
if ($res->getStatusCode() === 200) { if ($res->getStatusCode() === 200) {
$metadata = $this->ogpResolver->parse($res->getBody()); $metadata = $this->ogpResolver->parse($res->getBody());
parse_str(parse_url($metadata->image, PHP_URL_QUERY), $temp); parse_str(parse_url($metadata->image, PHP_URL_QUERY), $query);
$expires_at_unixtime = $temp['token-time']; if (isset($query['token-time'])) {
$expires_at = Carbon::createFromTimestamp($expires_at_unixtime); $expires_at_unixtime = $query['token-time'];
$metadata->expires_at = Carbon::createFromTimestamp($expires_at_unixtime);
$metadata->expires_at = $expires_at; }
return $metadata; return $metadata;
} else { } else {

View File

@ -0,0 +1,44 @@
<?php
namespace App\MetadataResolver;
use GuzzleHttp\Client;
class PlurkResolver implements Resolver
{
/**
* @var Client
*/
private $client;
/**
* @var OGPResolver
*/
private $ogpResolver;
public function __construct(Client $client, OGPResolver $ogpResolver)
{
$this->client = $client;
$this->ogpResolver = $ogpResolver;
}
public function resolve(string $url): Metadata
{
$res = $this->client->get($url);
if ($res->getStatusCode() === 200) {
$metadata = $this->ogpResolver->parse($res->getBody());
$dom = new \DOMDocument();
@$dom->loadHTML(mb_convert_encoding($res->getBody(), 'HTML-ENTITIES', 'UTF-8'));
$xpath = new \DOMXPath($dom);
$imageNode = $xpath->query('//div[@class="text_holder"]/a[1]')->item(0);
if ($imageNode) {
$metadata->image = $imageNode->getAttribute('href');
}
return $metadata;
} else {
throw new \RuntimeException("{$res->getStatusCode()}: $url");
}
}
}

View File

@ -25,6 +25,8 @@ class AuthServiceProvider extends ServiceProvider
{ {
$this->registerPolicies(); $this->registerPolicies();
// Gate::define('admin', function ($user) {
return $user->is_admin;
});
} }
} }

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddIsAdminToUsers extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('users', function (Blueprint $table) {
$table->boolean('is_admin')->default(false);
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('is_admin');
});
}
}

View File

@ -3,6 +3,15 @@ set -e
if [[ "$APP_DEBUG" == "true" ]]; then if [[ "$APP_DEBUG" == "true" ]]; then
export PHP_INI_SCAN_DIR=":/usr/local/etc/php/php.d" export PHP_INI_SCAN_DIR=":/usr/local/etc/php/php.d"
php -r "if (gethostbyname('host.docker.internal') === 'host.docker.internal') exit(1);" &> /dev/null && :
if [[ $? -eq 0 ]]; then
# Docker for Windows/Mac
export PHP_XDEBUG_REMOTE_HOST='host.docker.internal'
else
# Docker for Linux
export PHP_XDEBUG_REMOTE_HOST=$(cat /etc/hosts | awk 'END{print $1}' | sed -r -e 's/[0-9]+$/1/g')
fi
fi fi
exec docker-php-entrypoint "$@" exec docker-php-entrypoint "$@"

View File

@ -1,4 +1,4 @@
; Dockerでのデバッグ用設定 ; Dockerでのデバッグ用設定
zend_extension=xdebug.so zend_extension=xdebug.so
xdebug.remote_enable=true xdebug.remote_enable=true
xdebug.remote_host=host.docker.internal xdebug.remote_host=${PHP_XDEBUG_REMOTE_HOST}

View File

@ -7,21 +7,38 @@
"watch-poll": "npm run watch -- --watch-poll", "watch-poll": "npm run watch -- --watch-poll",
"hot": "cross-env NODE_ENV=development node_modules/webpack-dev-server/bin/webpack-dev-server.js --inline --hot --config=node_modules/laravel-mix/setup/webpack.config.js", "hot": "cross-env NODE_ENV=development node_modules/webpack-dev-server/bin/webpack-dev-server.js --inline --hot --config=node_modules/laravel-mix/setup/webpack.config.js",
"prod": "npm run production", "prod": "npm run production",
"production": "cross-env NODE_ENV=production node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js" "production": "cross-env NODE_ENV=production node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js",
"stylelint": "stylelint resources/assets/sass/**/*"
}, },
"devDependencies": { "devDependencies": {
"bootstrap": "^4.2.1", "bootstrap": "^4.3.1",
"cal-heatmap": "^3.3.10", "cal-heatmap": "^3.3.10",
"chart.js": "^2.7.1", "chart.js": "^2.7.1",
"cross-env": "^5.2.0", "cross-env": "^5.2.0",
"husky": "^1.3.1",
"jquery": "^3.2.1", "jquery": "^3.2.1",
"js-cookie": "^2.2.0", "js-cookie": "^2.2.0",
"laravel-mix": "^4.0.0", "laravel-mix": "^4.0.0",
"laravel-mix-bundle-analyzer": "^1.0.2",
"lint-staged": "^8.1.5",
"open-iconic": "^1.1.1", "open-iconic": "^1.1.1",
"popper.js": "^1.14.7", "popper.js": "^1.14.7",
"resolve-url-loader": "^2.3.1", "resolve-url-loader": "^2.3.1",
"sass": "^1.17.0", "sass": "^1.17.0",
"sass-loader": "^7.1.0", "sass-loader": "^7.1.0",
"stylelint": "^9.10.1",
"stylelint-config-recess-order": "^2.0.1",
"vue-template-compiler": "^2.6.6" "vue-template-compiler": "^2.6.6"
},
"stylelint": {
"extends": "stylelint-config-recess-order"
},
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"*.{css,scss}": ["stylelint --fix", "git add"]
} }
} }

BIN
public/dashboard.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View File

@ -21,12 +21,10 @@ $(() => {
$('.alert').alert(); $('.alert').alert();
$('.tis-page-selector').pageSelector(); $('.tis-page-selector').pageSelector();
if (document.getElementById('status')) {
setTimeout(function () {
$('#status').alert('close');
}, 5000);
}
$('.link-card').linkCard(); $('.link-card').linkCard();
$('#deleteCheckinModal').deleteCheckinModal(); const $deleteCheckinModal = $('#deleteCheckinModal').deleteCheckinModal();
$(document).on('click', '[data-target="#deleteCheckinModal"]', function (event) {
event.preventDefault();
$deleteCheckinModal.modal('show', this);
});
}); });

View File

@ -0,0 +1,19 @@
.card-img-left {
width: 100%;
@include border-left-radius($card-inner-border-radius);
}
.card-img-right {
width: 100%;
@include border-right-radius($card-inner-border-radius);
}
.card-img-top-to-left {
width: 100%;
@include media-breakpoint-down(md) {
@include border-top-radius($card-inner-border-radius);
}
@include media-breakpoint-up(lg) {
@include border-left-radius($card-inner-border-radius);
}
}

View File

@ -1,8 +1,15 @@
// Bootstrap Variable Overlide
$primary: #e53fb1;
// Bootstrap // Bootstrap
@import "~bootstrap/scss/bootstrap"; @import "~bootstrap/scss/bootstrap";
@import "bootstrap-custom";
// Open Iconic // Open Iconic
@import "~open-iconic/font/css/open-iconic-bootstrap"; @import "~open-iconic/font/css/open-iconic-bootstrap";
// Legacy app styles // Legacy app styles
@import "tissue.css"; @import "tissue.css";
// Components
@import "components/link-card";

View File

@ -0,0 +1,21 @@
.link-card {
.row > div:last-child {
max-height: 400px;
overflow: hidden;
// 省略を表す影を付けるやつ
&::before {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
content: '';
background: linear-gradient(transparent 320px, white);
}
}
.card-text {
white-space: pre-line;
}
}

View File

@ -1,14 +1,10 @@
@charset "UTF-8"; @charset "UTF-8";
.tis-footer { .tis-footer {
color: #a2a2a2;
font-size: small; font-size: small;
border-top: 1px solid #eee; color: #a2a2a2;
background: linear-gradient(to bottom, #f8f9fa, #fff) background: linear-gradient(to bottom, #f8f9fa, #fff);
} border-top: 1px solid #eee
.tis-word-wrap {
word-wrap: break-word;
} }
.tis-contribution-graph { .tis-contribution-graph {
@ -16,8 +12,8 @@
} }
.tis-need-agecheck .container { .tis-need-agecheck .container {
filter: blur(45px);
pointer-events: none; pointer-events: none;
filter: blur(45px);
} }
.container { .container {
@ -25,8 +21,8 @@
} }
.list-group-item.no-side-border { .list-group-item.no-side-border {
border-left: none;
border-right: none; border-right: none;
border-left: none;
border-radius: 0; border-radius: 0;
} }
@ -35,8 +31,8 @@
} }
.list-group-item.border-bottom-only { .list-group-item.border-bottom-only {
border-left: none;
border-right: none; border-right: none;
border-left: none;
border-radius: 0; border-radius: 0;
} }
@ -54,12 +50,12 @@
} }
.tis-page-selector { .tis-page-selector {
margin-left: -1px;
width: calc(100% + 2px); width: calc(100% + 2px);
height: 100%; height: 100%;
margin-left: -1px;
line-height: 1.25;
border: 1px solid #dee2e6; border: 1px solid #dee2e6;
border-radius: 0; border-radius: 0;
line-height: 1.25;
} }
@media (min-width: 992px) { @media (min-width: 992px) {
@ -69,13 +65,13 @@
} }
#navbarNav > .d-lg-none > .row > div:first-of-type { #navbarNav > .d-lg-none > .row > div:first-of-type {
padding-left: 15px;
padding-right: 7.5px; padding-right: 7.5px;
padding-left: 15px;
} }
#navbarNav > .d-lg-none > .row > div { #navbarNav > .d-lg-none > .row > div {
padding-left: 7.5px;
padding-right: 15px; padding-right: 15px;
padding-left: 7.5px;
} }
#navbarNav > .d-lg-none > .row > .col .btn { #navbarNav > .d-lg-none > .row > .col .btn {
@ -85,15 +81,3 @@
#navbarAccountDropdownSp { #navbarAccountDropdownSp {
max-width: calc(100vw - 5em); max-width: calc(100vw - 5em);
} }
.card-img-left {
width: 100%;
border-top-left-radius: calc(.25rem - 1px);
border-bottom-left-radius: calc(.25rem - 1px);
}
.card-img-right {
width: 100%;
border-top-right-radius: calc(.25rem - 1px);
border-bottom-right-radius: calc(.25rem - 1px);
}

View File

@ -119,6 +119,8 @@ return [
'attributes' => [ 'attributes' => [
'email' => 'メールアドレス', 'email' => 'メールアドレス',
'password' => 'パスワード', 'password' => 'パスワード',
'title' => 'タイトル',
'content' => '本文',
], ],
]; ];

View File

@ -0,0 +1,10 @@
@extends('layouts.admin')
@section('title', 'ダッシュボード')
@section('tab-content')
<div class="container d-flex flex-column align-items-center">
<img src="{{ asset('dashboard.png') }}" class="w-50"/>
<p class="text-muted">TODO: 役に立つ情報を表示する</p>
</div>
@endsection

View File

@ -0,0 +1,54 @@
@extends('layouts.admin')
@section('title', 'お知らせ')
@section('tab-content')
<div class="container">
<h2>お知らせの作成</h2>
<hr>
<form action="{{ route('admin.info.store') }}" method="post">
{{ csrf_field() }}
<div class="row">
<div class="form-group col-12 col-lg-6">
<label for="category">カテゴリ</label>
<select id="category" name="category" class="form-control">
@foreach($categories as $id => $category)
<option value="{{ $id }}" {{ old('category') == $id ? 'selected' : '' }}>{{ $category['label'] }}</option>
@endforeach
</select>
</div>
<div class="form-group col-12 col-lg-6 d-flex flex-column justify-content-center">
<div class="custom-control custom-checkbox">
<input id="pinned" name="pinned" type="checkbox" class="custom-control-input" value="1"
{{ old('pinned') ? 'checked' : ''}}>
<label for="pinned" class="custom-control-label">ピン留め (常に優先表示) する</label>
</div>
</div>
</div>
<div class="form-group">
<label for="title">タイトル</label>
<input id="title" name="title" type="text" class="form-control {{ $errors->has('title') ? ' is-invalid' : '' }}" value="{{ old('title') }}">
@if ($errors->has('title'))
<div class="invalid-feedback">{{ $errors->first('title') }}</div>
@endif
</div>
<div class="form-group mt-3">
<label for="content">本文</label>
<textarea id="content" name="content" rows="15" class="form-control {{ $errors->has('content') ? ' is-invalid' : '' }}" maxlength="10000">{{ old('content') }}</textarea>
<small class="form-text text-muted">
最大 10000 文字、Markdown 形式
</small>
@if ($errors->has('content'))
<div class="invalid-feedback">{{ $errors->first('content') }}</div>
@endif
</div>
<div class="d-flex justify-content-between">
<button type="submit" class="btn btn-primary">登録</button>
</div>
</form>
</div>
@endsection

View File

@ -0,0 +1,60 @@
@extends('layouts.admin')
@section('title', 'お知らせ')
@section('tab-content')
<div class="container">
<h2>お知らせの編集</h2>
<hr>
<form action="{{ route('admin.info.update', ['info' => $info]) }}" method="post">
{{ method_field('PUT') }}
{{ csrf_field() }}
<div class="row">
<div class="form-group col-12 col-lg-6">
<label for="category">カテゴリ</label>
<select id="category" name="category" class="form-control">
@foreach($categories as $id => $category)
<option value="{{ $id }}" {{ (old('category') ?? $info->category) == $id ? 'selected' : '' }}>{{ $category['label'] }}</option>
@endforeach
</select>
</div>
<div class="form-group col-12 col-lg-6 d-flex flex-column justify-content-center">
<div class="custom-control custom-checkbox">
<input id="pinned" name="pinned" type="checkbox" class="custom-control-input" value="1"
{{ (is_bool(old('pinned')) ? old('pinned') : $info->pinned) ? 'checked' : ''}}>
<label for="pinned" class="custom-control-label">ピン留め (常に優先表示) する</label>
</div>
</div>
</div>
<div class="form-group">
<label for="title">タイトル</label>
<input id="title" name="title" type="text" class="form-control {{ $errors->has('title') ? ' is-invalid' : '' }}" value="{{ old('title') ?? $info->title }}">
@if ($errors->has('title'))
<div class="invalid-feedback">{{ $errors->first('title') }}</div>
@endif
</div>
<div class="form-group mt-3">
<label for="content">本文</label>
<textarea id="content" name="content" rows="15" class="form-control {{ $errors->has('content') ? ' is-invalid' : '' }}" maxlength="10000">{{ old('content') ?? $info->content }}</textarea>
<small class="form-text text-muted">
最大 10000 文字、Markdown 形式
</small>
@if ($errors->has('content'))
<div class="invalid-feedback">{{ $errors->first('content') }}</div>
@endif
</div>
<div class="d-flex justify-content-between">
<button type="submit" class="btn btn-primary">更新</button>
<button type="submit" class="btn btn-danger" form="delete-form">削除</button>
</div>
</form>
<form id="delete-form" action="{{ route('admin.info.destroy', ['info' => $info]) }}" method="post">
{{ method_field('DELETE') }}
{{ csrf_field() }}
</form>
</div>
@endsection

View File

@ -0,0 +1,57 @@
@extends('layouts.admin')
@section('title', 'お知らせ')
@section('tab-content')
<div class="container">
<h2>お知らせ</h2>
<hr>
<div class="d-flex mb-3">
<a href="{{ route('admin.info.create') }}" class="btn btn-primary">新規作成</a>
</div>
<table class="table table-sm">
<thead>
<tr>
<th>カテゴリ</th>
<th>タイトル</th>
<th>作成日</th>
</tr>
</thead>
<tbody>
@foreach($informations as $info)
<tr>
<td>
@if ($info->pinned)
<span class="badge badge-secondary"><span class="oi oi-pin"></span>ピン留め</span>
@endif
<span class="badge {{ $categories[$info->category]['class'] }}">{{ $categories[$info->category]['label'] }}</span>
</td>
<td>
<a href="{{ route('admin.info.edit', ['id' => $info->id]) }}">{{ $info->title }}</a>
</td>
<td>
{{ $info->created_at->format('Y年n月j日') }}
</td>
</tr>
@endforeach
</tbody>
</table>
<ul class="pagination mt-4 justify-content-center">
<li class="page-item {{ $informations->currentPage() === 1 ? 'disabled' : '' }}">
<a class="page-link" href="{{ $informations->previousPageUrl() }}" aria-label="Previous">
<span aria-hidden="true">&laquo;</span>
<span class="sr-only">Previous</span>
</a>
</li>
@for ($i = 1; $i <= $informations->lastPage(); $i++)
<li class="page-item {{ $i === $informations->currentPage() ? 'active' : '' }}"><a href="{{ $informations->url($i) }}" class="page-link">{{ $i }}</a></li>
@endfor
<li class="page-item {{ $informations->currentPage() === $informations->lastPage() ? 'disabled' : '' }}">
<a class="page-link" href="{{ $informations->nextPageUrl() }}" aria-label="Next">
<span aria-hidden="true">&raquo;</span>
<span class="sr-only">Next</span>
</a>
</li>
</ul>
</div>
@endsection

View File

@ -2,7 +2,7 @@
<a class="text-dark card-link" href="{{ $link }}" target="_blank" rel="noopener"> <a class="text-dark card-link" href="{{ $link }}" target="_blank" rel="noopener">
<div class="row no-gutters"> <div class="row no-gutters">
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<img src="" alt="Thumbnail" class="card-img-left bg-secondary"> <img src="" alt="Thumbnail" class="card-img-top-to-left bg-secondary">
</div> </div>
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<div class="card-body"> <div class="card-body">

View File

@ -36,7 +36,7 @@
<a class="text-secondary timeline-action-item" href="{{ route('checkin', ['link' => $ejaculation->link, 'tags' => $ejaculation->textTags()]) }}"><span class="oi oi-reload" data-toggle="tooltip" data-placement="bottom" title="同じオカズでチェックイン"></span></a> <a class="text-secondary timeline-action-item" href="{{ route('checkin', ['link' => $ejaculation->link, 'tags' => $ejaculation->textTags()]) }}"><span class="oi oi-reload" data-toggle="tooltip" data-placement="bottom" title="同じオカズでチェックイン"></span></a>
@if ($user->isMe()) @if ($user->isMe())
<a class="text-secondary timeline-action-item" href="{{ route('checkin.edit', ['id' => $ejaculation->id]) }}"><span class="oi oi-pencil" data-toggle="tooltip" data-placement="bottom" title="修正"></span></a> <a class="text-secondary timeline-action-item" href="{{ route('checkin.edit', ['id' => $ejaculation->id]) }}"><span class="oi oi-pencil" data-toggle="tooltip" data-placement="bottom" title="修正"></span></a>
<a class="text-secondary timeline-action-item" href="#" data-toggle="modal" data-target="#deleteCheckinModal" data-id="{{ $ejaculation->id }}" data-date="{{ $ejaculation->ejaculated_date }}"><span class="oi oi-trash" data-toggle="tooltip" data-placement="bottom" title="削除"></span></a> <a class="text-secondary timeline-action-item" href="#" data-target="#deleteCheckinModal" data-id="{{ $ejaculation->id }}" data-date="{{ $ejaculation->ejaculated_date }}"><span class="oi oi-trash" data-toggle="tooltip" data-placement="bottom" title="削除"></span></a>
@endif @endif
</div> </div>
</div> </div>
@ -63,7 +63,7 @@
@endif @endif
<!-- note --> <!-- note -->
@if (!empty($ejaculation->note)) @if (!empty($ejaculation->note))
<p class="mb-0 tis-word-wrap"> <p class="mb-0 text-break">
{!! Formatter::linkify(nl2br(e($ejaculation->note))) !!} {!! Formatter::linkify(nl2br(e($ejaculation->note))) !!}
</p> </p>
@endif @endif

View File

@ -54,7 +54,7 @@
<p class="text-secondary">最近の公開チェックインから、オカズリンク付きのものを表示しています。</p> <p class="text-secondary">最近の公開チェックインから、オカズリンク付きのものを表示しています。</p>
<ul class="list-group"> <ul class="list-group">
@foreach ($publicLinkedEjaculations as $ejaculation) @foreach ($publicLinkedEjaculations as $ejaculation)
<li class="list-group-item no-side-border pt-3 pb-3 tis-word-wrap"> <li class="list-group-item no-side-border pt-3 pb-3 text-break">
<!-- span --> <!-- span -->
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<h5> <h5>
@ -85,14 +85,14 @@
@endif @endif
<!-- note --> <!-- note -->
@if (!empty($ejaculation->note)) @if (!empty($ejaculation->note))
<p class="mb-0 tis-word-wrap"> <p class="mb-0 text-break">
{!! Formatter::linkify(nl2br(e($ejaculation->note))) !!} {!! Formatter::linkify(nl2br(e($ejaculation->note))) !!}
</p> </p>
@endif @endif
</li> </li>
@endforeach @endforeach
<li class="list-group-item no-side-border text-right"> <li class="list-group-item no-side-border text-right">
<a href="{{ route('timeline.public') }}">もっと見る &raquo;</a> <a href="{{ route('timeline.public') }}" class="stretched-link">もっと見る &raquo;</a>
</li> </li>
</ul> </ul>
@endif @endif
@ -104,5 +104,6 @@
@push('script') @push('script')
<script id="global-count-labels" type="application/json">@json(array_keys($globalEjaculationCounts))</script> <script id="global-count-labels" type="application/json">@json(array_keys($globalEjaculationCounts))</script>
<script id="global-count-data" type="application/json">@json(array_values($globalEjaculationCounts))</script> <script id="global-count-data" type="application/json">@json(array_values($globalEjaculationCounts))</script>
<script src="{{ mix('js/vendor/chart.js') }}"></script>
<script src="{{ mix('js/home.js') }}"></script> <script src="{{ mix('js/home.js') }}"></script>
@endpush @endpush

View File

@ -0,0 +1,20 @@
@extends('layouts.base')
@section('content')
<div class="container">
<div class="row">
<div class="col-lg-3">
<div class="list-group mb-4">
<div class="list-group-item disabled font-weight-bold">管理</div>
<a class="list-group-item list-group-item-action {{ Route::is('admin.dashboard') ? 'active' : '' }}"
href="{{ route('admin.dashboard') }}"><span class="oi oi-dashboard mr-1"></span> ダッシュボード</a>
<a class="list-group-item list-group-item-action {{ Route::is('admin.info*') ? 'active' : '' }}"
href="{{ route('admin.info') }}"><span class="oi oi-bullhorn mr-1"></span> お知らせ</a>
</div>
</div>
<div class="tab-content col-lg-9">
@yield('tab-content')
</div>
</div>
</div>
@endsection

View File

@ -89,7 +89,7 @@
</div> </div>
</form> </form>
<form class="form-inline mr-2"> <form class="form-inline mr-2">
<a href="{{ route('checkin') }}" class="btn btn-outline-success">チェックイン</a> <a href="{{ route('checkin') }}" class="btn btn-outline-primary">チェックイン</a>
</form> </form>
<ul class="navbar-nav"> <ul class="navbar-nav">
<li class="nav-item dropdown"> <li class="nav-item dropdown">
@ -105,6 +105,9 @@
</a> </a>
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
<a href="{{ route('setting') }}" class="dropdown-item">設定</a> <a href="{{ route('setting') }}" class="dropdown-item">設定</a>
@can ('admin')
<a href="{{ route('admin.dashboard') }}" class="dropdown-item">管理</a>
@endcan
<a href="{{ route('logout') }}" class="dropdown-item" onclick="event.preventDefault(); document.getElementById('logout-form').submit();">ログアウト</a> <a href="{{ route('logout') }}" class="dropdown-item" onclick="event.preventDefault(); document.getElementById('logout-form').submit();">ログアウト</a>
</div> </div>
</li> </li>
@ -114,18 +117,18 @@
<div class="d-lg-none"> <div class="d-lg-none">
<div class="row mt-2"> <div class="row mt-2">
<div class="col"> <div class="col">
<a class="btn btn-outline-{{ stripos(Route::currentRouteName(), 'home') === 0 ? 'primary' : 'secondary'}}" href="{{ route('home') }}" role="button">ホーム</a> <a class="btn btn-{{ stripos(Route::currentRouteName(), 'home') === 0 ? 'primary' : 'outline-secondary'}}" href="{{ route('home') }}" role="button">ホーム</a>
</div> </div>
<div class="col"> <div class="col">
<a class="btn btn-outline-{{ stripos(Route::currentRouteName(), 'user.profile') === 0 ? 'primary' : 'secondary'}}" href="{{ route('user.profile', ['name' => Auth::user()->name]) }}" role="button">タイムライン</a> <a class="btn btn-{{ stripos(Route::currentRouteName(), 'user.profile') === 0 ? 'primary' : 'outline-secondary'}}" href="{{ route('user.profile', ['name' => Auth::user()->name]) }}" role="button">タイムライン</a>
</div> </div>
</div> </div>
<div class="row mt-2"> <div class="row mt-2">
<div class="col"> <div class="col">
<a class="btn btn-outline-{{ stripos(Route::currentRouteName(), 'user.stats') === 0 ? 'primary' : 'secondary'}}" href="{{ route('user.stats', ['name' => Auth::user()->name]) }}" role="button">グラフ</a> <a class="btn btn-{{ stripos(Route::currentRouteName(), 'user.stats') === 0 ? 'primary' : 'outline-secondary'}}" href="{{ route('user.stats', ['name' => Auth::user()->name]) }}" role="button">グラフ</a>
</div> </div>
<div class="col"> <div class="col">
<a class="btn btn-outline-{{ stripos(Route::currentRouteName(), 'user.okazu') === 0 ? 'primary' : 'secondary'}}" href="{{ route('user.okazu', ['name' => Auth::user()->name]) }}" role="button">オカズ</a> <a class="btn btn-{{ stripos(Route::currentRouteName(), 'user.okazu') === 0 ? 'primary' : 'outline-secondary'}}" href="{{ route('user.okazu', ['name' => Auth::user()->name]) }}" role="button">オカズ</a>
</div> </div>
</div> </div>
{{-- <div class="row mt-2"> {{-- <div class="row mt-2">
@ -145,7 +148,7 @@
</div> </div>
<div class="row mt-2"> <div class="row mt-2">
<form class="form-inline col"> <form class="form-inline col">
<a class="btn btn-outline-success" href="{{ route('checkin') }}">チェックイン</a> <a class="btn btn-outline-primary" href="{{ route('checkin') }}">チェックイン</a>
</form> </form>
</div> </div>
</div> </div>
@ -217,6 +220,8 @@
</div> </div>
</div> </div>
@endguest @endguest
<script src="{{ mix('js/manifest.js') }}"></script>
<script src="{{ mix('js/vendor.js') }}"></script>
<script src="{{ mix('js/app.js') }}"></script> <script src="{{ mix('js/app.js') }}"></script>
@stack('script') @stack('script')
</body> </body>

View File

@ -6,7 +6,7 @@
@else @else
<ul class="list-group"> <ul class="list-group">
@foreach($results as $ejaculation) @foreach($results as $ejaculation)
<li class="list-group-item border-bottom-only pt-3 pb-3 tis-word-wrap"> <li class="list-group-item border-bottom-only pt-3 pb-3 text-break">
<!-- span --> <!-- span -->
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<h5> <h5>
@ -15,10 +15,6 @@
</h5> </h5>
<div> <div>
<a class="text-secondary timeline-action-item" href="{{ route('checkin', ['link' => $ejaculation->link, 'tags' => $ejaculation->textTags()]) }}"><span class="oi oi-reload" data-toggle="tooltip" data-placement="bottom" title="同じオカズでチェックイン"></span></a> <a class="text-secondary timeline-action-item" href="{{ route('checkin', ['link' => $ejaculation->link, 'tags' => $ejaculation->textTags()]) }}"><span class="oi oi-reload" data-toggle="tooltip" data-placement="bottom" title="同じオカズでチェックイン"></span></a>
@if ($ejaculation->user->isMe())
<a class="text-secondary timeline-action-item" href="{{ route('checkin.edit', ['id' => $ejaculation->id]) }}"><span class="oi oi-pencil" data-toggle="tooltip" data-placement="bottom" title="修正"></span></a>
<a class="text-secondary timeline-action-item" href="#" data-toggle="modal" data-target="#deleteCheckinModal" data-id="{{ $ejaculation->id }}" data-date="{{ $ejaculation->ejaculated_date }}"><span class="oi oi-trash" data-toggle="tooltip" data-placement="bottom" title="削除"></span></a>
@endif
</div> </div>
</div> </div>
<!-- tags --> <!-- tags -->
@ -44,7 +40,7 @@
@endif @endif
<!-- note --> <!-- note -->
@if (!empty($ejaculation->note)) @if (!empty($ejaculation->note))
<p class="mb-0 tis-word-wrap"> <p class="mb-0 text-break">
{!! Formatter::linkify(nl2br(e($ejaculation->note))) !!} {!! Formatter::linkify(nl2br(e($ejaculation->note))) !!}
</p> </p>
@endif @endif

View File

@ -10,7 +10,7 @@
<div class="container-fluid"> <div class="container-fluid">
<div class="row mx-1"> <div class="row mx-1">
@foreach($ejaculations as $ejaculation) @foreach($ejaculations as $ejaculation)
<div class="col-12 col-lg-6 col-xl-4 py-3 tis-word-wrap border-top"> <div class="col-12 col-lg-6 col-xl-4 py-3 text-break border-top">
<!-- span --> <!-- span -->
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<h5> <h5>
@ -44,7 +44,7 @@
@endif @endif
<!-- note --> <!-- note -->
@if (!empty($ejaculation->note)) @if (!empty($ejaculation->note))
<p class="mb-0 tis-word-wrap"> <p class="mb-0 text-break">
{!! Formatter::linkify(nl2br(e($ejaculation->note))) !!} {!! Formatter::linkify(nl2br(e($ejaculation->note))) !!}
</p> </p>
@endif @endif

View File

@ -34,7 +34,7 @@
@else @else
<ul class="list-group"> <ul class="list-group">
@forelse ($ejaculations as $ejaculation) @forelse ($ejaculations as $ejaculation)
<li class="list-group-item border-bottom-only pt-3 pb-3 tis-word-wrap"> <li class="list-group-item border-bottom-only pt-3 pb-3 text-break">
<!-- span --> <!-- span -->
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<h5>{{ $ejaculation->ejaculated_span ?? '精通' }} <a href="{{ route('checkin.show', ['id' => $ejaculation->id]) }}" class="text-muted"><small>{{ $ejaculation->before_date }}{{ !empty($ejaculation->before_date) ? ' ' : '' }}{{ $ejaculation->ejaculated_date->format('Y/m/d H:i') }}</small></a></h5> <h5>{{ $ejaculation->ejaculated_span ?? '精通' }} <a href="{{ route('checkin.show', ['id' => $ejaculation->id]) }}" class="text-muted"><small>{{ $ejaculation->before_date }}{{ !empty($ejaculation->before_date) ? ' ' : '' }}{{ $ejaculation->ejaculated_date->format('Y/m/d H:i') }}</small></a></h5>
@ -42,7 +42,7 @@
<a class="text-secondary timeline-action-item" href="{{ route('checkin', ['link' => $ejaculation->link, 'tags' => $ejaculation->textTags()]) }}"><span class="oi oi-reload" data-toggle="tooltip" data-placement="bottom" title="同じオカズでチェックイン"></span></a> <a class="text-secondary timeline-action-item" href="{{ route('checkin', ['link' => $ejaculation->link, 'tags' => $ejaculation->textTags()]) }}"><span class="oi oi-reload" data-toggle="tooltip" data-placement="bottom" title="同じオカズでチェックイン"></span></a>
@if ($user->isMe()) @if ($user->isMe())
<a class="text-secondary timeline-action-item" href="{{ route('checkin.edit', ['id' => $ejaculation->id]) }}"><span class="oi oi-pencil" data-toggle="tooltip" data-placement="bottom" title="修正"></span></a> <a class="text-secondary timeline-action-item" href="{{ route('checkin.edit', ['id' => $ejaculation->id]) }}"><span class="oi oi-pencil" data-toggle="tooltip" data-placement="bottom" title="修正"></span></a>
<a class="text-secondary timeline-action-item" href="#" data-toggle="modal" data-target="#deleteCheckinModal" data-id="{{ $ejaculation->id }}" data-date="{{ $ejaculation->ejaculated_date }}"><span class="oi oi-trash" data-toggle="tooltip" data-placement="bottom" title="削除"></span></a> <a class="text-secondary timeline-action-item" href="#" data-target="#deleteCheckinModal" data-id="{{ $ejaculation->id }}" data-date="{{ $ejaculation->ejaculated_date }}"><span class="oi oi-trash" data-toggle="tooltip" data-placement="bottom" title="削除"></span></a>
@endif @endif
</div> </div>
</div> </div>
@ -69,7 +69,7 @@
@endif @endif
<!-- note --> <!-- note -->
@if (!empty($ejaculation->note)) @if (!empty($ejaculation->note))
<p class="mb-0 tis-word-wrap"> <p class="mb-0 text-break">
{!! Formatter::linkify(nl2br(e($ejaculation->note))) !!} {!! Formatter::linkify(nl2br(e($ejaculation->note))) !!}
</p> </p>
@endif @endif

View File

@ -30,6 +30,7 @@
@endsection @endsection
@push('script') @push('script')
<script id="graph-data" type="application/javascript">@json($graphData)</script> <script id="graph-data" type="application/json">@json($graphData)</script>
<script src="{{ mix('js/vendor/chart.js') }}"></script>
<script src="{{ mix('js/user/stats.js') }}"></script> <script src="{{ mix('js/user/stats.js') }}"></script>
@endpush @endpush

View File

@ -44,3 +44,17 @@ Route::get('/info/{id}', 'InfoController@show')->where('id', '[0-9]+')->name('in
Route::redirect('/search', '/search/checkin', 301); Route::redirect('/search', '/search/checkin', 301);
Route::get('/search/checkin', 'SearchController@index')->name('search'); Route::get('/search/checkin', 'SearchController@index')->name('search');
Route::get('/search/related-tag', 'SearchController@relatedTag')->name('search.related-tag'); Route::get('/search/related-tag', 'SearchController@relatedTag')->name('search.related-tag');
Route::middleware('can:admin')
->namespace('Admin')
->prefix('admin')
->name('admin.')
->group(function () {
Route::get('/', 'DashboardController@index')->name('dashboard');
Route::get('/info', 'InfoController@index')->name('info');
Route::get('/info/create', 'InfoController@create')->name('info.create');
Route::post('/info', 'InfoController@store')->name('info.store');
Route::get('/info/{info}', 'InfoController@edit')->name('info.edit');
Route::put('/info/{info}', 'InfoController@update')->name('info.update');
Route::delete('/info/{info}', 'InfoController@destroy')->name('info.destroy');
});

View File

@ -0,0 +1,80 @@
<?php
namespace Tests\Unit\Http\Middleware;
use App\Http\Middleware\NormalizeLineEnding;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Http\Request;
use Tests\TestCase;
class NormalizeLineEndingTest extends TestCase
{
public function testCRLFtoLF()
{
$request = Request::create('/');
$request->replace([
'test' => "foo\r\nbar"
]);
$middleware = new NormalizeLineEnding();
$middleware->handle($request, function (Request $request) {
$this->assertEquals("foo\nbar", $request->input('test'));
});
}
public function testCRtoLF()
{
$request = Request::create('/');
$request->replace([
'test' => "foo\rbar"
]);
$middleware = new NormalizeLineEnding();
$middleware->handle($request, function (Request $request) {
$this->assertEquals("foo\nbar", $request->input('test'));
});
}
public function testLFtoLF()
{
$request = Request::create('/');
$request->replace([
'test' => "foo\nbar"
]);
$middleware = new NormalizeLineEnding();
$middleware->handle($request, function (Request $request) {
$this->assertEquals("foo\nbar", $request->input('test'));
});
}
public function testArrayRequest()
{
$request = Request::create('/');
$request->replace([
'test' => "foo\r\nbar",
'hash' => [
'yuzuki' => "yuzuki\r\nyukari",
'miku' => "hatsune\r\nmiku",
],
'array' => [
"kagamine\r\nrin",
"kagamine\r\nlen"
]
]);
$middleware = new NormalizeLineEnding();
$middleware->handle($request, function (Request $request) {
$this->assertEquals("foo\nbar", $request->input('test'));
$this->assertEquals("yuzuki\nyukari", $request->input('hash.yuzuki'));
$this->assertEquals("hatsune\nmiku", $request->input('hash.miku'));
$this->assertEquals("kagamine\nrin", $request->input('array.0'));
$this->assertEquals("kagamine\nlen", $request->input('array.1'));
});
}
}

View File

@ -25,8 +25,8 @@ class DLsiteResolverTest extends TestCase
$this->createResolver(DLsiteResolver::class, $responseText); $this->createResolver(DLsiteResolver::class, $responseText);
$metadata = $this->resolver->resolve('https://www.dlsite.com/maniax/work/=/product_id/RJ171695.html'); $metadata = $this->resolver->resolve('https://www.dlsite.com/maniax/work/=/product_id/RJ171695.html');
$this->assertEquals('【骨伝導風】道草屋 たびらこ-一緒にはみがき【耳かき&はみがき】 [桃色CODE] | DLsite', $metadata->title); $this->assertEquals('【骨伝導風】道草屋 たびらこ-一緒にはみがき【耳かき&はみがき】', $metadata->title);
$this->assertStringStartsWith('少しお母さんっぽい店員さんに、歯磨きからおやすみまでお世話されます。はみがきで興奮しちゃった旦那様のも、しっかりお世話してくれます。歯磨き音は特殊なマイクを使用、骨伝導風ハイレゾバイノーラル音声です。', $metadata->description); $this->assertStringEndsWith('少しお母さんっぽい店員さんに、歯磨きからおやすみまでお世話されます。はみがきで興奮しちゃった旦那様のも、しっかりお世話してくれます。歯磨き音は特殊なマイクを使用、骨伝導風ハイレゾバイノーラル音声です。', $metadata->description);
$this->assertEquals('https://img.dlsite.jp/modpub/images2/work/doujin/RJ172000/RJ171695_img_main.jpg', $metadata->image); $this->assertEquals('https://img.dlsite.jp/modpub/images2/work/doujin/RJ172000/RJ171695_img_main.jpg', $metadata->image);
if ($this->shouldUseMock()) { if ($this->shouldUseMock()) {
$this->assertSame('https://www.dlsite.com/maniax/work/=/product_id/RJ171695.html', (string) $this->handler->getLastRequest()->getUri()); $this->assertSame('https://www.dlsite.com/maniax/work/=/product_id/RJ171695.html', (string) $this->handler->getLastRequest()->getUri());
@ -40,11 +40,26 @@ class DLsiteResolverTest extends TestCase
$this->createResolver(DLsiteResolver::class, $responseText); $this->createResolver(DLsiteResolver::class, $responseText);
$metadata = $this->resolver->resolve('https://www.dlsite.com/home/work/=/product_id/RJ234446.html'); $metadata = $this->resolver->resolve('https://www.dlsite.com/home/work/=/product_id/RJ234446.html');
$this->assertEquals('【大人向け耳かき】道草屋 はこべら5 時計修理のはこべらさん。他【汗の匂い】 [桃色CODE] | DLsite', $metadata->title); $this->assertEquals('【大人向け耳かき】道草屋 はこべら5 時計修理のはこべらさん。他【汗の匂い】', $metadata->title);
$this->assertStringStartsWith('夏の終わり、二人で遠くの花火を眺めます。耳かきの他、クラシックシェービング、氷を含んだあまがみ、冷紅茶、ジャズ、時計の修理、それから大人向けの汗の匂い。色々な事のある、二泊三日の田舎宿音声です。', $metadata->description); $this->assertStringEndsWith('夏の終わり、二人で遠くの花火を眺めます。耳かきの他、クラシックシェービング、氷を含んだあまがみ、冷紅茶、ジャズ、時計の修理、それから大人向けの汗の匂い。色々な事のある、二泊三日の田舎宿音声です。', $metadata->description);
$this->assertEquals('https://img.dlsite.jp/modpub/images2/work/doujin/RJ235000/RJ234446_img_main.jpg', $metadata->image); $this->assertEquals('https://img.dlsite.jp/modpub/images2/work/doujin/RJ235000/RJ234446_img_main.jpg', $metadata->image);
if ($this->shouldUseMock()) { if ($this->shouldUseMock()) {
$this->assertSame('https://www.dlsite.com/home/work/=/product_id/RJ234446.html', (string) $this->handler->getLastRequest()->getUri()); $this->assertSame('https://www.dlsite.com/home/work/=/product_id/RJ234446.html', (string) $this->handler->getLastRequest()->getUri());
} }
} }
public function testProductShortLink()
{
$responseText = file_get_contents(__DIR__.'/../../fixture/DLsite/testProduct.html');
$this->createResolver(DLsiteResolver::class, $responseText);
$metadata = $this->resolver->resolve('https://dlsite.jp/mawtw/RJ171695.html');
$this->assertEquals('【骨伝導風】道草屋 たびらこ-一緒にはみがき【耳かき&はみがき】', $metadata->title);
$this->assertStringEndsWith('少しお母さんっぽい店員さんに、歯磨きからおやすみまでお世話されます。はみがきで興奮しちゃった旦那様のも、しっかりお世話してくれます。歯磨き音は特殊なマイクを使用、骨伝導風ハイレゾバイノーラル音声です。', $metadata->description);
$this->assertEquals('https://img.dlsite.jp/modpub/images2/work/doujin/RJ172000/RJ171695_img_main.jpg', $metadata->image);
if ($this->shouldUseMock()) {
$this->assertSame('https://dlsite.jp/mawtw/RJ171695.html', (string) $this->handler->getLastRequest()->getUri());
}
}
} }

View File

@ -0,0 +1,50 @@
<?php
namespace Tests\Unit\MetadataResolver;
use App\MetadataResolver\FC2ContentsResolver;
use Tests\TestCase;
class FC2ContentsResolverTest extends TestCase
{
use CreateMockedResolver;
public function setUp()
{
parent::setUp();
if (!$this->shouldUseMock()) {
sleep(1);
}
}
public function testAdult()
{
$responseText = file_get_contents(__DIR__.'/../../fixture/FC2Contents/adult.html');
$this->createResolver(FC2ContentsResolver::class, $responseText);
$metadata = $this->resolver->resolve('https://adult.contents.fc2.com/article_search.php?id=401545');
$this->assertEquals('個人撮影「ぱいずりオアトリート♡」Jカップ魔女っ子の連挟射しても続けちゃうパイズリ', $metadata->title);
$this->assertEquals('個人撮影「ぱいずりオアトリート♡」Jカップ魔女っ子の連挟射しても続けちゃうパイズリ - イベントコスチュームということもあり、大ボリュームだった前回、前々回の パイズリ役cupメイド と ナースパイズリを超え 今回さらに超ボリューム&超密度の内容になってます! -------- …', $metadata->description);
$this->assertEquals('https://storage2000.contents.fc2.com/file/104/10362633/1477676255.72.png', $metadata->image);
if ($this->shouldUseMock()) {
$this->assertSame('https://adult.contents.fc2.com/article_search.php?id=401545', (string) $this->handler->getLastRequest()->getUri());
}
}
public function testGeneral()
{
$responseText = file_get_contents(__DIR__.'/../../fixture/FC2Contents/general.html');
$this->createResolver(FC2ContentsResolver::class, $responseText);
$metadata = $this->resolver->resolve('https://contents.fc2.com/article_search.php?id=336610');
$this->assertEquals('ゆかいなどうぶつたち ~オオカミ・キツネ・タヌキ~', $metadata->title);
$this->assertEquals('ゆかいなどうぶつたち ~オオカミ・キツネ・タヌキ~ - 今回のおともだちは、オオカミ・キツネ・タヌキだよ。地球上に住んでいるたくさんのおともだち、みんなにどんどん紹介するからたのしみにしてね!', $metadata->description);
$this->assertEquals('https://storage6000.contents.fc2.com/file/300/29917555/1519118184.65.jpg', $metadata->image);
if ($this->shouldUseMock()) {
$this->assertSame('https://contents.fc2.com/article_search.php?id=336610', (string) $this->handler->getLastRequest()->getUri());
}
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace Tests\Unit\MetadataResolver;
use App\MetadataResolver\PlurkResolver;
use Tests\TestCase;
class PlurkResolverTest extends TestCase
{
use CreateMockedResolver;
public function setUp()
{
parent::setUp();
if (!$this->shouldUseMock()) {
sleep(1);
}
}
public function test()
{
$responseText = file_get_contents(__DIR__.'/../../fixture/Plurk/test.html');
$this->createResolver(PlurkResolver::class, $responseText);
$metadata = $this->resolver->resolve('https://www.plurk.com/p/n0awli/');
$this->assertEquals('[R18]FC2實況中', $metadata->title);
$this->assertEquals('Plurk by 小虫/ムシ@台中種 - 71 response(s)', $metadata->description);
$this->assertEquals('https://images.plurk.com/5cT15Sf9OOFYk9fEQ759bZ.jpg', $metadata->image);
if ($this->shouldUseMock()) {
$this->assertSame('https://www.plurk.com/p/n0awli/', (string) $this->handler->getLastRequest()->getUri());
}
}
}

View File

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<title>個人撮影「ぱいずりオアトリート♡」Jカップ魔女っ子の連挟射しても続けちゃうパイズリ</title>
<meta charset="UTF-8">
<meta name="description" content="個人撮影「ぱいずりオアトリート♡」Jカップ魔女っ子の連挟射しても続けちゃうパイズリ - イベントコスチュームということもあり、大ボリュームだった前回、前々回の パイズリ役cupメイド と ナースパイズリを超え 今回さらに超ボリューム&超密度の内容になってます! -------- …">
</head>
<body>
<div class="main_thum_img">
<a class="analyticsLinkClick_mainThum" href="http://storage2000.contents.fc2.com/file/104/10362633/1477676255.72.png">
<img src="//contents-thumbnail2.fc2.com/w276/storage2000.contents.fc2.com/file/104/10362633/1477676255.72.png">
</a>
</div>
</body>
</html>

View File

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<title>ゆかいなどうぶつたち ~オオカミ・キツネ・タヌキ~</title>
<meta charset="UTF-8" />
<meta name="description" content="ゆかいなどうぶつたち ~オオカミ・キツネ・タヌキ~ - 今回のおともだちは、オオカミ・キツネ・タヌキだよ。地球上に住んでいるたくさんのおともだち、みんなにどんどん紹介するからたのしみにしてね!"/>
</head>
<body>
<div class="main_thum_img">
<a class="analyticsLinkClick_mainThum" href="http://storage6000.contents.fc2.com/file/300/29917555/1519118184.65.jpg">
<img src="//contents-thumbnail2.fc2.com/w276/storage6000.contents.fc2.com/file/300/29917555/1519118184.65.jpg"/>
</a>
</div>
</body>
</html>

View File

@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>小虫/ムシ@台中種 - [R18]FC2實況中 - Plurk</title>
<link rel="shortcut icon" type="image/png" href="//s.plurk.com/936ddc656e104792b651240cdafeb7aa.png">
<link rel="dns-prefetch" href="//avatars.plurk.com">
<link rel="dns-prefetch" href="//emos.plurk.com">
<link rel="dns-prefetch" href="//images.plurk.com">
<link rel="dns-prefetch" href="//imgs.plurk.com">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=0" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="fragment" content="!">
<meta property="og:type" content="article" />
<meta property="og:title" content="[R18]FC2實況中" />
<meta property="og:site_name" content="Plurk" />
<meta property="og:url" content="https://www.plurk.com/p/n0awli" />
<meta property="og:description" content="Plurk by 小虫/ムシ@台中種 - 71 response(s)" />
<meta property="og:image" content="https://s.plurk.com/6c6e2fb987651802af50e5f6a3853b40.png" />
<meta property="fb:app_id" content="47804741521"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge, chrome=1" />
<meta name="verify-v1" content="iBRwaQ/3d4NoF1uaa2SAfCJ962ORry1TE8/4XxtIbHk=" />
<meta name="viewport" content="user-scalable=no, initial-scale=1.0, maximum-scale=1.0, width=device-width">
<meta name="application-name" content="Plurk"/>
<meta name="msapplication-TileColor" content="#AA460F"/>
<meta name="msapplication-TileImage" content="//s.plurk.com/0964d8f7301cc4ee38b343ed154d2369.png"/>
</head>
<body>
<div class="content">
<div class="text_holder">
<a href="https://images.plurk.com/5cT15Sf9OOFYk9fEQ759bZ.jpg" class="ex_link pictureservices" rel="nofollow">
<img src="https://images.plurk.com/mx_5cT15Sf9OOFYk9fEQ759bZ.jpg" alt="https://images.plurk.com/5cT15Sf9OOFYk9fEQ759bZ.jpg" height="48">
</a>
<a href="https://images.plurk.com/2HdBlulzzXMZB7vITj4uOG.jpg" class="ex_link pictureservices" rel="nofollow">
<img src="https://images.plurk.com/mx_2HdBlulzzXMZB7vITj4uOG.jpg" alt="https://images.plurk.com/2HdBlulzzXMZB7vITj4uOG.jpg" height="48">
</a> [R18]FC2實況中
</div>
</div>
</body>
</html>

14
webpack.mix.js vendored
View File

@ -1,4 +1,5 @@
let mix = require('laravel-mix'); const mix = require('laravel-mix');
require('laravel-mix-bundle-analyzer')
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@ -13,10 +14,17 @@ let mix = require('laravel-mix');
mix.js('resources/assets/js/app.js', 'public/js') mix.js('resources/assets/js/app.js', 'public/js')
.js('resources/assets/js/home.js', 'public/js') .js('resources/assets/js/home.js', 'public/js')
.js('resources/assets/js/checkin.js', 'public/js')
.js('resources/assets/js/user/stats.js', 'public/js/user') .js('resources/assets/js/user/stats.js', 'public/js/user')
.js('resources/assets/js/setting/privacy.js', 'public/js/setting') .js('resources/assets/js/setting/privacy.js', 'public/js/setting')
.js('resources/assets/js/checkin.js', 'public/js')
.sass('resources/assets/sass/app.scss', 'public/css') .sass('resources/assets/sass/app.scss', 'public/css')
.autoload({ .autoload({
'jquery': ['$', 'jQuery', 'window.jQuery'] 'jquery': ['$', 'jQuery', 'window.jQuery']
}); })
.extract(['jquery', 'bootstrap'])
.extract(['chart.js', 'chartjs-color', 'color-name', 'moment'], 'public/js/vendor/chart')
.version();
if (process.argv.includes('-a')) {
mix.bundleAnalyzer({analyzerMode: 'static'});
}

1626
yarn.lock

File diff suppressed because it is too large Load Diff