完成引导页

main
落雨楓 2 years ago
parent 08fae6fd4c
commit 4b05175f9d

Binary file not shown.

After

Width:  |  Height:  |  Size: 744 KiB

@ -9,10 +9,18 @@ PODS:
- OrderedSet (~> 5.0)
- flutter_web_browser (0.17.1):
- Flutter
- FMDB (2.7.5):
- FMDB/standard (= 2.7.5)
- FMDB/standard (2.7.5)
- OrderedSet (5.0.0)
- package_info_plus (0.4.5):
- Flutter
- shared_preferences_ios (0.0.1):
- path_provider_ios (0.0.1):
- Flutter
- sqflite (0.0.2):
- Flutter
- FMDB (>= 2.7.5)
- url_launcher_ios (0.0.1):
- Flutter
- video_player_avfoundation (0.0.1):
- Flutter
@ -26,13 +34,16 @@ DEPENDENCIES:
- flutter_inappwebview (from `.symlinks/plugins/flutter_inappwebview/ios`)
- flutter_web_browser (from `.symlinks/plugins/flutter_web_browser/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- shared_preferences_ios (from `.symlinks/plugins/shared_preferences_ios/ios`)
- path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`)
- sqflite (from `.symlinks/plugins/sqflite/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/ios`)
- wakelock (from `.symlinks/plugins/wakelock/ios`)
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/ios`)
SPEC REPOS:
trunk:
- FMDB
- OrderedSet
EXTERNAL SOURCES:
@ -44,8 +55,12 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_web_browser/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
shared_preferences_ios:
:path: ".symlinks/plugins/shared_preferences_ios/ios"
path_provider_ios:
:path: ".symlinks/plugins/path_provider_ios/ios"
sqflite:
:path: ".symlinks/plugins/sqflite/ios"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
video_player_avfoundation:
:path: ".symlinks/plugins/video_player_avfoundation/ios"
wakelock:
@ -57,9 +72,12 @@ SPEC CHECKSUMS:
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
flutter_inappwebview: bfd58618f49dc62f2676de690fc6dcda1d6c3721
flutter_web_browser: 7bccaafbb0c5b8862afe7bcd158f15557109f61f
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c
package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e
shared_preferences_ios: 548a61f8053b9b8a49ac19c1ffbc8b92c50d68ad
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904
url_launcher_ios: 839c58cdb4279282219f5e248c3321761ff3c4de
video_player_avfoundation: e489aac24ef5cf7af82702979ed16f2a5ef84cff
wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f
webview_flutter_wkwebview: b7e70ef1ddded7e69c796c7390ee74180182971f

@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Isekai Wiki</string>
<string>异世界百科</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>

@ -61,7 +61,8 @@ class BaseApi {
// Header
dio.interceptors.add(
InterceptorsWrapper(onRequest: (options, handler) {
options.headers["X-IsekaiWikiApp-Version"] = Global.packageInfo?.version ?? "unknow";
options.headers["X-IsekaiWikiApp-Version"] =
Global.packageInfo?.version ?? "unknow";
options.headers["User-Agent"] = "";
return handler.next(options);
}),
@ -90,24 +91,27 @@ class BaseApi {
);
if (res.statusCode != null && res.statusCode != 200) {
throw HttpResponseException(res.statusCode!, statusText: res.statusMessage!);
throw HttpResponseException(res.statusCode!,
statusText: res.statusMessage!);
}
return res.data ?? "";
}
static Future<Map> getJson(Uri uri, {Map<String, dynamic>? search}) async {
static Future<Map<String, dynamic>> getJson(Uri uri,
{Map<String, dynamic>? search}) async {
var resText = await get(uri, search: search);
var resData = jsonDecode(resText);
if (resData is Map) {
if (resData is Map<String, dynamic>) {
return resData;
} else {
return {};
}
}
static Future<String> post(Uri uri, {Map<String, dynamic>? search, dynamic data}) async {
static Future<String> post(Uri uri,
{Map<String, dynamic>? search, dynamic data}) async {
var client = await getClient();
String? contentType;
@ -119,17 +123,20 @@ class BaseApi {
uri.toString(),
queryParameters: search,
data: data,
options: Options(responseType: ResponseType.plain, contentType: contentType),
options:
Options(responseType: ResponseType.plain, contentType: contentType),
);
if (res.statusCode != null && res.statusCode != 200) {
throw HttpResponseException(res.statusCode!, statusText: res.statusMessage!);
throw HttpResponseException(res.statusCode!,
statusText: res.statusMessage!);
}
return res.data ?? "";
}
static Future<Map> postJson(Uri uri, {Map<String, dynamic>? search, dynamic data}) async {
static Future<Map> postJson(Uri uri,
{Map<String, dynamic>? search, dynamic data}) async {
var resText = await post(uri, search: search, data: data);
var resData = jsonDecode(resText);

@ -1,8 +1,10 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:isekai_wiki/global.dart';
import 'package:isekai_wiki/models/settings.dart';
import 'package:isekai_wiki/models/user.dart';
import 'package:isekai_wiki/pages/welcome_page.dart';
import 'models/model.dart';
import 'pages/tab_page.dart';
import 'styles.dart';
@ -10,6 +12,14 @@ import 'styles.dart';
class IsekaiWikiApp extends StatelessWidget {
const IsekaiWikiApp({super.key});
Widget _buildApp(BuildContext context) {
if (Global.isAppActive) {
return const IsekaiWikiTabsPage();
} else {
return const WelcomePage();
}
}
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
@ -28,7 +38,7 @@ class IsekaiWikiApp extends StatelessWidget {
DefaultCupertinoLocalizations.delegate,
],
initialBinding: InitialBinding(),
home: const IsekaiWikiTabsPage(),
home: _buildApp(context),
builder: (context, child) {
if (child == null) {
return Container();

@ -1,10 +1,16 @@
import 'package:package_info_plus/package_info_plus.dart';
import 'package:shared_preferences/shared_preferences.dart';
typedef VoidFutureCallback = Future<void> Function();
typedef BoolFutureCallback = Future<bool> Function();
class Global {
static const String siteTitle = "异世界百科";
static const String siteDescription = "一起创造新世界吧!";
static const String privacyPolicyUrl =
"https://www.isekai.cn/%E5%BC%82%E4%B8%96%E7%95%8C%E7%99%BE%E7%A7%91:%E9%9A%90%E7%A7%81%E6%94%BF%E7%AD%96";
static const String siteConfigUrl = "https://www.isekai.cn/app/config.json";
static const String wikiApiUrl = "https://www.isekai.cn/api.php";
@ -23,5 +29,6 @@ class Global {
static String? webOrigin;
static SharedPreferences? sharedPreferences;
//
static bool isAppActive = false;
}

@ -8,7 +8,6 @@ import 'package:get_storage/get_storage.dart';
import 'package:isekai_wiki/global.dart';
import 'package:isekai_wiki/models/lifecycle.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'app.dart';
@ -24,8 +23,11 @@ Future<void> init() async {
Global.webOrigin = Uri.base.origin;
}
Global.sharedPreferences = await SharedPreferences.getInstance();
await GetStorage.init();
//
var storage = GetStorage();
Global.isAppActive = storage.read<bool>("appActive") ?? false;
}
Future<void> postInit() async {

@ -2,8 +2,8 @@ import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../global.dart';
@ -15,7 +15,8 @@ class AppSettings {
AppSettings({this.betaPageRender});
factory AppSettings.fromJson(Map<String, dynamic> json) => _$AppSettingsFromJson(json);
factory AppSettings.fromJson(Map<String, dynamic> json) =>
_$AppSettingsFromJson(json);
Map<String, dynamic> toJson() => _$AppSettingsToJson(this);
}
@ -37,10 +38,10 @@ class AppSettingsController extends GetxController {
}
///
Future<void> loadFromStorage() async {
void loadFromStorage() {
try {
final prefs = await SharedPreferences.getInstance();
var settingsJson = prefs.getString("settings");
final storage = GetStorage();
var settingsJson = storage.read<String>("settings");
if (settingsJson == null) return;
var settingsObject = jsonDecode(settingsJson);
@ -49,7 +50,8 @@ class AppSettingsController extends GetxController {
var settingsData = AppSettings.fromJson(settingsObject);
_ignoreSave = true;
betaPageRender.value = settingsData.betaPageRender ?? betaPageRender.value;
betaPageRender.value =
settingsData.betaPageRender ?? betaPageRender.value;
_ignoreSave = false;
} catch (ex) {
if (kDebugMode) {
@ -64,12 +66,12 @@ class AppSettingsController extends GetxController {
void saveToStorage() {
if (_ignoreSave) return;
final prefs = Global.sharedPreferences!;
final storage = GetStorage();
var settingsData = AppSettings(betaPageRender: betaPageRender.value);
var settingsJson = jsonEncode(settingsData.toJson());
prefs.setString("settings", settingsJson);
storage.write("settings", settingsJson);
}
}

@ -2,8 +2,9 @@ import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:isekai_wiki/api/base_api.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../global.dart';
@ -21,7 +22,8 @@ class SiteConfig {
this.renderTheme = Global.renderThemeFallback,
});
factory SiteConfig.fromJson(Map<String, dynamic> json) => _$SiteConfigFromJson(json);
factory SiteConfig.fromJson(Map<String, dynamic> json) =>
_$SiteConfigFromJson(json);
Map<String, dynamic> toJson() => _$SiteConfigToJson(this);
}
@ -29,7 +31,7 @@ class SiteConfig {
class AppSettingsController extends GetxController {
bool _ignoreSave = false;
bool isInit = false;
bool isAppActive = false;
List<String> moduleStyles = [];
List<String> moduleScripts = [];
@ -41,43 +43,47 @@ class AppSettingsController extends GetxController {
loadFromStorage();
if (isInit) {
if (isAppActive) {
// APP
loadFromRemote();
loadFromRemote().catchError((err, stack) {
if (kDebugMode) {
print("Cannot update site config: $err");
stack.printError();
}
});
}
}
///
Future<void> loadFromStorage() async {
void loadFromStorage() {
try {
final prefs = await SharedPreferences.getInstance();
var siteConfigJson = prefs.getString("siteConfigCache");
final storage = GetStorage();
isAppActive = storage.read<bool>("appActive") ?? false;
var siteConfigJson = storage.read<String>("siteConfigCache");
if (siteConfigJson == null) return;
var siteConfigObject = jsonDecode(siteConfigJson);
if (siteConfigObject == null) return;
var siteConfigData = SiteConfig.fromJson(siteConfigObject);
moduleScripts = siteConfigData.moduleScripts;
moduleStyles = siteConfigData.moduleStyles;
renderTheme = siteConfigData.renderTheme;
} catch (ex) {
loadFromEntity(siteConfigData);
} catch (ex, stack) {
if (kDebugMode) {
print(ex);
stack.printError();
}
} finally {
_ignoreSave = false;
}
}
Future<void> loadFromRemote() async {}
///
void saveToStorage() {
if (_ignoreSave) return;
final prefs = Global.sharedPreferences!;
final storage = GetStorage();
var siteConfigData = SiteConfig(
moduleScripts: moduleScripts,
@ -87,6 +93,20 @@ class AppSettingsController extends GetxController {
var siteConfigJson = jsonEncode(siteConfigData.toJson());
prefs.setString("siteConfigCache", siteConfigJson);
storage.write("siteConfigCache", siteConfigJson);
}
void loadFromEntity(SiteConfig siteConfigData) {
moduleScripts = siteConfigData.moduleScripts;
moduleStyles = siteConfigData.moduleStyles;
renderTheme = siteConfigData.renderTheme;
}
Future<void> loadFromRemote() async {
Uri siteConfigUri = Uri.parse(Global.siteConfigUrl);
var resMap = await BaseApi.getJson(siteConfigUri);
var siteConfigData = SiteConfig.fromJson(resMap);
loadFromEntity(siteConfigData);
}
}

@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_web_browser/flutter_web_browser.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:isekai_wiki/api/base_api.dart';
import 'package:isekai_wiki/api/mw/mw_api.dart';
import 'package:isekai_wiki/api/mw/user.dart';
@ -30,7 +31,8 @@ class UserInfo {
this.avatarUrlSet,
});
factory UserInfo.fromJson(Map<String, dynamic> json) => _$UserInfoFromJson(json);
factory UserInfo.fromJson(Map<String, dynamic> json) =>
_$UserInfoFromJson(json);
Map<String, dynamic> toJson() => _$UserInfoToJson(this);
}
@ -113,8 +115,8 @@ class UserController extends GetxController {
///
void loadFromStorage() {
try {
final prefs = Global.sharedPreferences!;
var userInfoJson = prefs.getString("userInfo");
final storage = GetStorage();
var userInfoJson = storage.read<String>("userInfo");
if (userInfoJson == null) return;
var userInfoObject = jsonDecode(userInfoJson);
@ -129,7 +131,7 @@ class UserController extends GetxController {
avatarUrlSet.value = userInfo.avatarUrlSet ?? {};
_ignoreSave = false;
var savedLoginRequestToken = prefs.getString("loginRequestToken");
var savedLoginRequestToken = storage.read<String>("loginRequestToken");
if (savedLoginRequestToken != null) {
_ignoreSave = true;
loginRequestToken.value = savedLoginRequestToken;
@ -148,7 +150,7 @@ class UserController extends GetxController {
void saveToStorage() {
if (_ignoreSave) return;
final prefs = Global.sharedPreferences!;
final storage = GetStorage();
var userInfo = UserInfo(
userId: userId.value,
@ -159,12 +161,12 @@ class UserController extends GetxController {
var userInfoJson = jsonEncode(userInfo.toJson());
prefs.setString("userInfo", userInfoJson);
storage.write("userInfo", userInfoJson);
if (loginRequestToken.isNotEmpty) {
prefs.setString("loginRequestToken", loginRequestToken.value);
} else if (prefs.containsKey("loginRequestToken")) {
prefs.remove("loginRequestToken");
storage.write("loginRequestToken", loginRequestToken.value);
} else {
storage.remove("loginRequestToken");
}
}
@ -236,13 +238,15 @@ class UserController extends GetxController {
Future<void> logout({bool logoutRemote = true}) async {
if (logoutRemote) {
//
authProcessing.value = true;
try {
await MWApiUser.logout();
} catch (err, stack) {
authProcessing.value = false;
alert(Get.overlayContext!, ErrorUtils.getErrorMessage(err), title: "错误");
alert(Get.overlayContext!, ErrorUtils.getErrorMessage(err),
title: "错误");
if (kDebugMode) {
print("Exception in logout: $err");
stack.printError();

@ -0,0 +1,177 @@
import 'package:flutter/cupertino.dart';
import 'package:get/get.dart';
import 'package:isekai_wiki/components/isekai_page_scaffold.dart';
import 'package:isekai_wiki/global.dart';
import 'package:isekai_wiki/utils/dialog.dart';
import 'package:roundcheckbox/roundcheckbox.dart';
class WelcomePageController extends GetxController {
var isLoading = false.obs;
var policyAccepted = false.obs;
void handleClickPolicyLink() {
openUrl(Global.privacyPolicyUrl, inApp: false);
}
}
class WelcomePage extends StatelessWidget {
const WelcomePage({super.key});
Widget _buildTitleImage(BuildContext context, WelcomePageController c) {
return Center(
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(10)),
child: Image.asset("images/title.png"),
),
);
}
Widget _buildSiteTitle(BuildContext context, WelcomePageController c) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
MediaQuery(
data: MediaQueryData(textScaleFactor: 1),
child: Text(
Global.siteTitle,
style: TextStyle(fontSize: 48),
),
),
Text(Global.siteDescription),
],
);
}
Widget _buildActions(BuildContext context, WelcomePageController c) {
var theme = CupertinoTheme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
c.policyAccepted.value = !c.policyAccepted.value;
},
child: Row(
children: [
Obx(
() => RoundCheckBox(
size: 30,
isChecked: c.policyAccepted.value,
onTap: (checked) {
c.policyAccepted.value = checked ?? false;
},
animationDuration: Duration.zero,
checkedColor: theme.primaryColor,
),
),
const SizedBox(
width: 10,
),
const Text("已阅读并同意"),
CupertinoButton(
padding: EdgeInsets.zero,
onPressed: c.handleClickPolicyLink,
child: const Text("隐私政策"),
)
],
),
),
const SizedBox(
height: 18,
),
Obx(
() => CupertinoButton.filled(
disabledColor: theme.primaryColor.withOpacity(0.6),
onPressed: c.policyAccepted.value && !c.isLoading.value
? () {
c.isLoading.value = true;
Future.delayed(const Duration(seconds: 5)).then((value) {
c.isLoading.value = false;
});
}
: null,
child: const Text("继续"),
),
),
],
);
}
@override
Widget build(BuildContext context) {
var c = Get.put(WelcomePageController());
return IsekaiPageScaffold(
backgroundColor: CupertinoColors.white,
child: SafeArea(
child: OrientationBuilder(
builder: (context, orientation) => Padding(
padding: orientation == Orientation.portrait
? const EdgeInsets.only(
top: 32, right: 20, bottom: 32, left: 20)
: const EdgeInsets.symmetric(horizontal: 20, vertical: 32),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Flexible(
child: orientation == Orientation.portrait
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Flexible(
flex: 1,
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight:
MediaQuery.of(context).size.width *
0.75),
child: _buildTitleImage(context, c),
),
),
const SizedBox(height: 12),
Flexible(
flex: 0,
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 24, horizontal: 8),
child: _buildSiteTitle(context, c),
),
),
],
)
: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Flexible(
flex: 1,
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight:
MediaQuery.of(context).size.width *
9 /
16),
child: _buildTitleImage(context, c),
),
),
const SizedBox(width: 48),
Flexible(
flex: 1,
child: _buildSiteTitle(context, c),
),
],
),
),
const SizedBox(height: 36),
_buildActions(context, c),
],
),
),
),
),
);
}
}

@ -6,10 +6,14 @@ abstract class Styles {
static bool isXs = false;
static const CupertinoTextThemeData defaultTextTheme = CupertinoTextThemeData();
static const CupertinoTextThemeData defaultTextTheme =
CupertinoTextThemeData();
static const TextStyle navLargeTitleTextStyle = TextStyle(
fontWeight: FontWeight.normal, fontSize: 32, color: CupertinoColors.label, inherit: false);
fontWeight: FontWeight.normal,
fontSize: 32,
color: CupertinoColors.label,
inherit: false);
static const TextStyle productRowItemName = TextStyle(
color: Color.fromRGBO(0, 0, 0, 0.8),
@ -52,7 +56,8 @@ abstract class Styles {
static const Color themeMainColor = Color.fromRGBO(65, 147, 135, 1);
static const Color themeNavTitleColor = Color.fromRGBO(255, 255, 255, 1);
static const Color themeBottomColor = Color.fromRGBO(255, 255, 255, 1);
static const Color themePageBackgroundColor = Color.fromRGBO(240, 240, 240, 1);
static const Color themePageBackgroundColor =
Color.fromRGBO(240, 240, 240, 1);
static const Color themeNavSegmentTextColor = Color.fromRGBO(12, 12, 12, 1);
static const Color panelBackgroundColor = Color.fromRGBO(255, 255, 255, 1);
static const Color linkColor = CupertinoColors.link;

@ -33,7 +33,10 @@ Future<void> alert(BuildContext context, String content, {String? title}) {
}
Future<bool> confirm(BuildContext context, String content,
{String? title, String? positiveText, String? negativeText, bool isDanger = false}) {
{String? title,
String? positiveText,
String? negativeText,
bool isDanger = false}) {
var c = Completer<bool>();
positiveText ??= "";
@ -89,5 +92,5 @@ Future<void> openUrl(String url, {bool inApp = false}) async {
}
}
//
launchUrlString(url);
launchUrlString(url, mode: LaunchMode.externalApplication);
}

@ -7,7 +7,6 @@ import Foundation
import package_info_plus
import path_provider_macos
import shared_preferences_macos
import sqflite
import url_launcher_macos
import wakelock_macos
@ -15,7 +14,6 @@ import wakelock_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FLTPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FLTPackageInfoPlusPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
WakelockMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockMacosPlugin"))

File diff suppressed because it is too large Load Diff

@ -44,10 +44,11 @@ dependencies:
animations: ^2.0.4
flutter_displaymode: ^0.3.2
flutter_scale_tap: ^1.0.5
shared_preferences: ^2.0.15
roundcheckbox: ^2.0.5
like_button: ^2.0.4
skeletons: ^0.0.3
modal_bottom_sheet: ^2.1.2
responsive_builder: ^0.4.3
url_launcher: ^6.1.7
flutter_web_browser: ^0.17.1
flutter_inappwebview: ^5.7.2+3
@ -97,6 +98,8 @@ flutter:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
assets:
- images/title.png
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/assets-and-images/#resolution-aware

@ -0,0 +1 @@
/Users/hyperzlib/Projects/isekai_wiki_app/images
Loading…
Cancel
Save