diff --git a/Pipfile b/Pipfile index 4d377014ae..f75fb3d98a 100644 --- a/Pipfile +++ b/Pipfile @@ -20,6 +20,7 @@ typing-extensions = "*" flask-jwt-extended = "==4.6.0" wtforms = "==3.1.2" sqlalchemy = "*" +flask-bcrypt = "*" [requires] python_version = "3.13" diff --git a/Pipfile.lock b/Pipfile.lock index d9e474e972..38336a5b2a 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "ffbfb32d0afa5e4bcaba5c2d08c81381a97abd90f22284d2b76647365df5dc50" + "sha256": "9c612f7eebcf717533779dc02b40fce5eeaf66bfab71c81d166828128c272240" }, "pipfile-spec": 6, "requires": { @@ -24,6 +24,75 @@ "markers": "python_version >= '3.10'", "version": "==1.17.1" }, + "bcrypt": { + "hashes": [ + "sha256:046ad6db88edb3c5ece4369af997938fb1c19d6a699b9c1b27b0db432faae4c4", + "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", + "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", + "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", + "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", + "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", + "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", + "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", + "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", + "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", + "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", + "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", + "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", + "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", + "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", + "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", + "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", + "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", + "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", + "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", + "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", + "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", + "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", + "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4", + "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", + "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", + "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", + "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", + "sha256:7edda91d5ab52b15636d9c30da87d2cc84f426c72b9dba7a9b4fe142ba11f534", + "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", + "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", + "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", + "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", + "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", + "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", + "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", + "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", + "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", + "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", + "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", + "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", + "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", + "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", + "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", + "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", + "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", + "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", + "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", + "sha256:dcd58e2b3a908b5ecc9b9df2f0085592506ac2d5110786018ee5e160f28e0911", + "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", + "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", + "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", + "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", + "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", + "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", + "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", + "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", + "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", + "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", + "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", + "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", + "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", + "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b" + ], + "markers": "python_version >= '3.8'", + "version": "==5.0.0" + }, "blinker": { "hashes": [ "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", @@ -42,11 +111,11 @@ }, "click": { "hashes": [ - "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", - "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4" + "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", + "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96" ], "markers": "python_version >= '3.10'", - "version": "==8.3.0" + "version": "==8.4.1" }, "cloudinary": { "hashes": [ @@ -58,12 +127,12 @@ }, "flask": { "hashes": [ - "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87", - "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c" + "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", + "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c" ], "index": "pypi", "markers": "python_version >= '3.9'", - "version": "==3.1.2" + "version": "==3.1.3" }, "flask-admin": { "hashes": [ @@ -74,6 +143,14 @@ "markers": "python_version >= '3.10'", "version": "==2.0.0" }, + "flask-bcrypt": { + "hashes": [ + "sha256:062fd991dc9118d05ac0583675507b9fe4670e44416c97e0e6819d03d01f808a", + "sha256:f07b66b811417ea64eb188ae6455b0b708a793d966e1a80ceec4a23bc42a4369" + ], + "index": "pypi", + "version": "==1.0.1" + }, "flask-cors": { "hashes": [ "sha256:c7b2cbfb1a31aa0d2e5341eea03a6805349f7a61647daee1a15c46bbe981494c", @@ -575,11 +652,11 @@ }, "werkzeug": { "hashes": [ - "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", - "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746" + "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", + "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44" ], "markers": "python_version >= '3.9'", - "version": "==3.1.3" + "version": "==3.1.8" }, "wtforms": { "hashes": [ diff --git a/index.html b/index.html index 27a99f796e..053231b6da 100644 --- a/index.html +++ b/index.html @@ -5,6 +5,7 @@ + Hello Rigo diff --git a/migrations/versions/0763d677d453_.py b/migrations/versions/0763d677d453_.py deleted file mode 100644 index 88964176f1..0000000000 --- a/migrations/versions/0763d677d453_.py +++ /dev/null @@ -1,35 +0,0 @@ -"""empty message - -Revision ID: 0763d677d453 -Revises: -Create Date: 2025-02-25 14:47:16.337069 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = '0763d677d453' -down_revision = None -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('user', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('email', sa.String(length=120), nullable=False), - sa.Column('password', sa.String(), nullable=False), - sa.Column('is_active', sa.Boolean(), nullable=False), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('email') - ) - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('user') - # ### end Alembic commands ### diff --git a/migrations/versions/3b9cabc9e89c_.py b/migrations/versions/3b9cabc9e89c_.py new file mode 100644 index 0000000000..377d0464e6 --- /dev/null +++ b/migrations/versions/3b9cabc9e89c_.py @@ -0,0 +1,42 @@ +"""empty message + +Revision ID: 3b9cabc9e89c +Revises: 565ffd5984d2 +Create Date: 2026-06-19 20:17:46.106958 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '3b9cabc9e89c' +down_revision = '565ffd5984d2' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.add_column(sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False)) + batch_op.add_column(sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False)) + batch_op.alter_column('img', + existing_type=sa.VARCHAR(length=255), + type_=sa.Text(), + existing_nullable=True) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.alter_column('img', + existing_type=sa.Text(), + type_=sa.VARCHAR(length=255), + existing_nullable=True) + batch_op.drop_column('updated_at') + batch_op.drop_column('created_at') + + # ### end Alembic commands ### diff --git a/migrations/versions/b25cdff97e19_.py b/migrations/versions/b25cdff97e19_.py new file mode 100644 index 0000000000..5929b844bb --- /dev/null +++ b/migrations/versions/b25cdff97e19_.py @@ -0,0 +1,83 @@ +"""empty message + +Revision ID: b25cdff97e19 +Revises: +Create Date: 2026-06-17 19:42:08.904130 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'b25cdff97e19' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('tag_select', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('foro_id', sa.Integer(), nullable=True), + sa.Column('tag_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['foro_id'], ['foro.id'], ), + sa.ForeignKeyConstraint(['tag_id'], ['tag.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('foro', schema=None) as batch_op: + batch_op.alter_column('img', + existing_type=sa.VARCHAR(length=255), + type_=sa.Text(), + existing_nullable=True) + batch_op.alter_column('description', + existing_type=sa.VARCHAR(length=255), + type_=sa.Text(), + existing_nullable=True) + + with op.batch_alter_table('post', schema=None) as batch_op: + batch_op.add_column(sa.Column('title', sa.String(length=120), nullable=False)) + batch_op.alter_column('img', + existing_type=sa.VARCHAR(length=255), + type_=sa.Text(), + existing_nullable=True) + + with op.batch_alter_table('tag', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('tag_foro_id_fkey'), type_='foreignkey') + batch_op.drop_constraint(batch_op.f('tag_user_id_fkey'), type_='foreignkey') + batch_op.drop_column('foro_id') + batch_op.drop_column('user_id') + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('tag', schema=None) as batch_op: + batch_op.add_column(sa.Column('user_id', sa.INTEGER(), autoincrement=False, nullable=False)) + batch_op.add_column(sa.Column('foro_id', sa.INTEGER(), autoincrement=False, nullable=False)) + batch_op.create_foreign_key(batch_op.f('tag_user_id_fkey'), 'user', ['user_id'], ['id']) + batch_op.create_foreign_key(batch_op.f('tag_foro_id_fkey'), 'foro', ['foro_id'], ['id']) + + with op.batch_alter_table('post', schema=None) as batch_op: + batch_op.alter_column('img', + existing_type=sa.Text(), + type_=sa.VARCHAR(length=255), + existing_nullable=True) + batch_op.drop_column('title') + + with op.batch_alter_table('foro', schema=None) as batch_op: + batch_op.alter_column('description', + existing_type=sa.Text(), + type_=sa.VARCHAR(length=255), + existing_nullable=True) + batch_op.alter_column('img', + existing_type=sa.Text(), + type_=sa.VARCHAR(length=255), + existing_nullable=True) + + op.drop_table('tag_select') + # ### end Alembic commands ### diff --git a/package-lock.json b/package-lock.json index 8d43d98ab7..96b93b961d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "prop-types": "^15.8.1", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-icons": "^5.6.0", "react-router-dom": "^6.18.0" }, "devDependencies": { @@ -877,19 +878,6 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", - "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", @@ -997,14 +985,6 @@ "@babel/types": "^7.20.7" } }, - "node_modules/@types/node": { - "version": "16.11.12", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.12.tgz", - "integrity": "sha512-+2Iggwg7PxoO5Kyhvsq9VarmPbIelXP070HMImEpbtGCoyWNINQj4wzjbQCXzdHTRXnqufutJb5KAURZANNBAw==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/@types/prop-types": { "version": "15.7.14", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", @@ -1307,14 +1287,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -3506,6 +3478,15 @@ "react": "^18.3.1" } }, + "node_modules/react-icons": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.6.0.tgz", + "integrity": "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -3915,29 +3896,6 @@ "node": ">=0.10.0" } }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/source-map-support/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/string.prototype.matchall": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", @@ -4074,35 +4032,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/terser": { - "version": "5.38.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.38.1.tgz", - "integrity": "sha512-GWANVlPM/ZfYzuPHjq0nxT+EbOEDDN3Jwhwdg1D8TU8oSkktp8w64Uq4auuGLxFSoNTRDncTq2hQHX1Ld9KHkA==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.8.2", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/terser/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -4944,18 +4873,6 @@ "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", "dev": true }, - "@jridgewell/source-map": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", - "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25" - } - }, "@jridgewell/sourcemap-codec": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", @@ -5044,14 +4961,6 @@ "@babel/types": "^7.20.7" } }, - "@types/node": { - "version": "16.11.12", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.12.tgz", - "integrity": "sha512-+2Iggwg7PxoO5Kyhvsq9VarmPbIelXP070HMImEpbtGCoyWNINQj4wzjbQCXzdHTRXnqufutJb5KAURZANNBAw==", - "dev": true, - "optional": true, - "peer": true - }, "@types/prop-types": { "version": "15.7.14", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", @@ -5252,14 +5161,6 @@ "update-browserslist-db": "^1.1.1" } }, - "buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "optional": true, - "peer": true - }, "call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -6715,6 +6616,12 @@ "scheduler": "^0.23.2" } }, + "react-icons": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.6.0.tgz", + "integrity": "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==", + "requires": {} + }, "react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -6982,28 +6889,6 @@ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true }, - "source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - }, - "dependencies": { - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "optional": true, - "peer": true - } - } - }, "string.prototype.matchall": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", @@ -7094,30 +6979,6 @@ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "dev": true }, - "terser": { - "version": "5.38.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.38.1.tgz", - "integrity": "sha512-GWANVlPM/ZfYzuPHjq0nxT+EbOEDDN3Jwhwdg1D8TU8oSkktp8w64Uq4auuGLxFSoNTRDncTq2hQHX1Ld9KHkA==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.8.2", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "dependencies": { - "commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, - "optional": true, - "peer": true - } - } - }, "text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", diff --git a/package.json b/package.json index 0caab10749..05b8fd5fb9 100755 --- a/package.json +++ b/package.json @@ -8,10 +8,10 @@ "main": "index.js", "scripts": { "dev": "vite", - "start": "vite", - "build": "vite build", - "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", - "preview": "vite preview" + "start": "vite", + "build": "vite build", + "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview" }, "author": { "name": "Alejandro Sanchez", @@ -30,13 +30,13 @@ "license": "ISC", "devDependencies": { "@types/react": "^18.2.18", - "@types/react-dom": "^18.2.7", - "@vitejs/plugin-react": "^4.0.4", - "eslint": "^8.46.0", - "eslint-plugin-react": "^7.33.1", - "eslint-plugin-react-hooks": "^4.6.0", - "eslint-plugin-react-refresh": "^0.4.3", - "vite": "^4.4.8" + "@types/react-dom": "^18.2.7", + "@vitejs/plugin-react": "^4.0.4", + "eslint": "^8.46.0", + "eslint-plugin-react": "^7.33.1", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.3", + "vite": "^4.4.8" }, "babel": { "presets": [ @@ -55,8 +55,9 @@ }, "dependencies": { "prop-types": "^15.8.1", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-router-dom": "^6.18.0" + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-icons": "^5.6.0", + "react-router-dom": "^6.18.0" } } diff --git a/src/api/admin.py b/src/api/admin.py index d1bd1d25ba..e450adc950 100644 --- a/src/api/admin.py +++ b/src/api/admin.py @@ -1,8 +1,8 @@ import os import inspect from flask_admin import Admin -from . import models -from .models import db +from api import models +from api.database.db import db from flask_admin.contrib.sqla import ModelView from flask_admin.theme import Bootstrap4Theme diff --git a/src/api/cloudinary/cloudinary_config.py b/src/api/cloudinary/cloudinary_config.py new file mode 100644 index 0000000000..18230f12a3 --- /dev/null +++ b/src/api/cloudinary/cloudinary_config.py @@ -0,0 +1,14 @@ +import os +import cloudinary + +print("CLOUD_NAME =", os.getenv("CLOUDINARY_CLOUD_NAME")) +print("API_KEY =", os.getenv("CLOUDINARY_API_KEY")) +print("API_SECRET =", os.getenv("CLOUDINARY_API_SECRET")) + + +cloudinary.config( + cloud_name= os.getenv("CLOUDINARY_CLOUD_NAME"), + api_key= os.getenv("CLOUDINARY_API_KEY"), + api_secret= os.getenv("CLOUDINARY_API_SECRET"), + secure=True +) \ No newline at end of file diff --git a/src/api/commands.py b/src/api/commands.py index 19806164d3..d934329676 100644 --- a/src/api/commands.py +++ b/src/api/commands.py @@ -1,6 +1,6 @@ import click -from api.models import db, User +from api.models.models_user import db, User """ In this file, you can add as many commands as you want using the @app.cli.command decorator diff --git a/src/api/database/db.py b/src/api/database/db.py new file mode 100644 index 0000000000..f0b13d6f2a --- /dev/null +++ b/src/api/database/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/src/api/extension.py b/src/api/extension.py new file mode 100644 index 0000000000..4f7710b60b --- /dev/null +++ b/src/api/extension.py @@ -0,0 +1,3 @@ +from flask_bcrypt import Bcrypt + +bcrypt = Bcrypt() \ No newline at end of file diff --git a/src/api/models.py b/src/api/models.py deleted file mode 100644 index da515f6a1a..0000000000 --- a/src/api/models.py +++ /dev/null @@ -1,19 +0,0 @@ -from flask_sqlalchemy import SQLAlchemy -from sqlalchemy import String, Boolean -from sqlalchemy.orm import Mapped, mapped_column - -db = SQLAlchemy() - -class User(db.Model): - id: Mapped[int] = mapped_column(primary_key=True) - email: Mapped[str] = mapped_column(String(120), unique=True, nullable=False) - password: Mapped[str] = mapped_column(nullable=False) - is_active: Mapped[bool] = mapped_column(Boolean(), nullable=False) - - - def serialize(self): - return { - "id": self.id, - "email": self.email, - # do not serialize the password, its a security breach - } \ No newline at end of file diff --git a/src/api/models/__init__.py b/src/api/models/__init__.py new file mode 100644 index 0000000000..a69108f65a --- /dev/null +++ b/src/api/models/__init__.py @@ -0,0 +1,7 @@ +from api.database.db import db +from api.models.models_user import User +from api.models.models_foro import Foro +from api.models.model_post import Post +from api.models.model_tag import Tag +from api.models.model_select_tag import Tag_select + diff --git a/src/api/models/model_post.py b/src/api/models/model_post.py new file mode 100644 index 0000000000..d8b0c4d97a --- /dev/null +++ b/src/api/models/model_post.py @@ -0,0 +1,33 @@ +from api.database.db import db +from sqlalchemy import String, Boolean, Text, ForeignKey, func +from sqlalchemy.orm import Mapped, mapped_column, relationship +from typing import List +from datetime import datetime + + + + +class Post (db.Model): + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] = mapped_column(String(120), nullable=False) + content: Mapped[str] = mapped_column(String(255),nullable=False) + img: Mapped[str] = mapped_column(Text,nullable=True) + user_id = mapped_column(ForeignKey("user.id"), nullable=False) + foro_id: Mapped[int] = mapped_column(ForeignKey("foro.id"), nullable=True) + foro = relationship("Foro", back_populates="post") + created_at: Mapped[datetime] = mapped_column(server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(server_default=func.now(), onupdate=func.now()) + + + def serialize_post(self): + return { + "id": self.id, + "title": self.title, + "content": self.content, + "img": self.img, + "user_id": self.user_id, + "foro_id": self.foro_id, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None + + } \ No newline at end of file diff --git a/src/api/models/model_select_tag.py b/src/api/models/model_select_tag.py new file mode 100644 index 0000000000..0720a4443f --- /dev/null +++ b/src/api/models/model_select_tag.py @@ -0,0 +1,30 @@ +from api.database.db import db +from sqlalchemy import String, Boolean, ForeignKey, func +from sqlalchemy.orm import Mapped, mapped_column, relationship +from typing import List + + + + +class Tag_select(db.Model): + id: Mapped[int] = mapped_column(primary_key=True) + user_id = mapped_column(ForeignKey("user.id"), nullable=True) + foro_id = mapped_column(ForeignKey("foro.id"), nullable=True) + tag_id = mapped_column(ForeignKey("tag.id"), nullable=False) + user = db.relationship('User', overlaps="tag") + foro = db.relationship('Foro', overlaps="tag") + tag = db.relationship('Tag', overlaps="tag") + + def serialize_tag_user(self): + return{ + "id": self.id, + "user_id": self.user_id, + "tag": self.tag.serialize_tag() + } + + def serialize_tag_foro(self): + return{ + "id": self.id, + "foro_id": self.foro_id, + "tag": self.tag.serialize_tag() + } \ No newline at end of file diff --git a/src/api/models/model_tag.py b/src/api/models/model_tag.py new file mode 100644 index 0000000000..bde433fd9f --- /dev/null +++ b/src/api/models/model_tag.py @@ -0,0 +1,17 @@ +from api.database.db import db +from sqlalchemy import String, Boolean, ForeignKey, func +from sqlalchemy.orm import Mapped, mapped_column, relationship +from typing import List + + + +class Tag(db.Model): + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] = mapped_column(String(120), unique=True, nullable=False) + tag = relationship('Tag_select') + + def serialize_tag(self): + return{ + "id": self.id, + "title": self.title, + } \ No newline at end of file diff --git a/src/api/models/models_foro.py b/src/api/models/models_foro.py new file mode 100644 index 0000000000..cf3d4ebd8d --- /dev/null +++ b/src/api/models/models_foro.py @@ -0,0 +1,31 @@ +from api.database.db import db +from sqlalchemy import String, Boolean, Text, ForeignKey, func +from sqlalchemy.orm import Mapped, mapped_column, relationship +from typing import List +from datetime import datetime + + + +class Foro(db.Model): + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] = mapped_column(String(120), nullable=False) + img: Mapped[str] = mapped_column(Text, nullable=True) + description: Mapped[str] = mapped_column(Text, nullable=True) + user_id = mapped_column(ForeignKey("user.id"), nullable=False) + tag = relationship('Tag_select') + post = relationship('Post') + created_at: Mapped[datetime] = mapped_column(server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(server_default=func.now(), onupdate=func.now()) + + + def serialize_foro(self): + return { + "id": self.id, + "title": self.title, + "img": self.img, + "description": self.description, + "user_id": self.user_id, + "created_at": str(self.created_at), + "updated_at": str(self. updated_at) + + } \ No newline at end of file diff --git a/src/api/models/models_user.py b/src/api/models/models_user.py new file mode 100644 index 0000000000..cd4b5e1f98 --- /dev/null +++ b/src/api/models/models_user.py @@ -0,0 +1,46 @@ +from api.database.db import db +from sqlalchemy import String, Boolean, ForeignKey, func, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship +from typing import List +from datetime import datetime + + +class User(db.Model): + id: Mapped[int] = mapped_column(primary_key=True) + username: Mapped[str]= mapped_column(String(120), unique=True, nullable=False) + email: Mapped[str] = mapped_column(String(120), unique=True, nullable=False) + password: Mapped[str] = mapped_column(String(255),nullable=False) + img: Mapped[str] = mapped_column(Text, nullable=True) + first_name: Mapped[str] = mapped_column(String(255),nullable=True) + last_name: Mapped[str] = mapped_column(String(255),nullable=True) + date_birth: Mapped[str] = mapped_column(String(255),nullable=True) + description: Mapped[str] = mapped_column(String(255),nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean(), default=True) + foro = relationship('Foro') + post = relationship('Post') + tag = relationship('Tag_select') + created_at: Mapped[datetime] = mapped_column(server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(server_default=func.now(), onupdate=func.now()) + + + def serialize(self): + return { + "id": self.id, + "username": self.username, + "email": self.email, + "created_at": str(self.created_at), + "updated_at": str(self.updated_at) + } + + def serialize_all(self): + return{ + "id": self.id, + "username": self.username, + "email": self.email, + "img": self.img, + "first_name": self.first_name, + "last_name": self.last_name, + "date_birth": self.date_birth, + "description": self.description, + "updated_at": str(self.updated_at) + } \ No newline at end of file diff --git a/src/api/routes.py b/src/api/routes.py deleted file mode 100644 index 029589a3a1..0000000000 --- a/src/api/routes.py +++ /dev/null @@ -1,22 +0,0 @@ -""" -This module takes care of starting the API Server, Loading the DB and Adding the endpoints -""" -from flask import Flask, request, jsonify, url_for, Blueprint -from api.models import db, User -from api.utils import generate_sitemap, APIException -from flask_cors import CORS - -api = Blueprint('api', __name__) - -# Allow CORS requests to this API -CORS(api) - - -@api.route('/hello', methods=['POST', 'GET']) -def handle_hello(): - - response_body = { - "message": "Hello! I'm a message that came from the backend, check the network tab on the google inspector and you will see the GET request" - } - - return jsonify(response_body), 200 diff --git a/src/api/routes/__init__.py b/src/api/routes/__init__.py new file mode 100644 index 0000000000..1050b5d813 --- /dev/null +++ b/src/api/routes/__init__.py @@ -0,0 +1,8 @@ +from flask import Blueprint +from flask_cors import CORS + +api = Blueprint('api', __name__) +CORS(api) + +# Las rutas se registran importando los módulos DESPUÉS de crear el blueprint +from api.routes import routes_user, routes_tag, routes_foro, routes_post \ No newline at end of file diff --git a/src/api/routes/routes_foro.py b/src/api/routes/routes_foro.py new file mode 100644 index 0000000000..964f6f1aa7 --- /dev/null +++ b/src/api/routes/routes_foro.py @@ -0,0 +1,90 @@ +from flask import request, jsonify +from flask_jwt_extended import jwt_required, get_jwt_identity +from api.models.models_foro import Foro +from api.database.db import db +from . import api + +import cloudinary +import cloudinary.uploader +from api.cloudinary.cloudinary_config import * + +@api.route('/foro', methods=["POST"]) +@jwt_required() +def create_forum(): + + title = request.form.get("title") + description = request.form.get("description") + + if not title: + return jsonify({"msg": "Title is required"}), 400 + + img_url = None + + if 'img' in request.files: + file_to_upload = request.files['img'] + if file_to_upload.filename != '': + try: + upload_result = cloudinary.uploader.upload(file_to_upload) + img_url = upload_result["secure_url"] + except Exception as e: + return jsonify({"msg": str(e)}), 500 + + new_forum = Foro( + title=title, + img=img_url, + description=description, + user_id=int(get_jwt_identity()) + ) + + db.session.add(new_forum) + db.session.commit() + + return jsonify({ + "msg": "Forum created", + "forum": new_forum.serialize_foro() + }), 201 + +@api.route('/foros', methods=['GET']) +def get_forums(): + + forums = Foro.query.all() + + return jsonify([ + forum.serialize_foro() + for forum in forums + ]), 200 + + +@api.route("/foro/", methods=["GET"]) +def get_forum_id(forum_id): + + forum = Foro.query.get(forum_id) + + if forum is None: + return jsonify({ + "msg": "Forum not found" + }), 404 + + return jsonify( + forum.serialize_foro() + ), 200 + + +@api.route("/foros/search", methods=["GET"]) +def get_forum_search(): + + query = request.args.get("query") + + if not query: + return jsonify({ + "msg": "Query is required" + }), 400 + + forums = Foro.query.filter( + Foro.title.ilike(f"%{query}%") + ).all() + + return jsonify([ + forum.serialize_foro() + for forum in forums + ]), 200 \ No newline at end of file diff --git a/src/api/routes/routes_post.py b/src/api/routes/routes_post.py new file mode 100644 index 0000000000..febc0891af --- /dev/null +++ b/src/api/routes/routes_post.py @@ -0,0 +1,116 @@ +from flask import request, jsonify +from flask_jwt_extended import jwt_required, get_jwt_identity +from api.models.model_post import Post +from api.database.db import db + +import cloudinary +import cloudinary.uploader +from api.cloudinary.cloudinary_config import * + +# Importación circular segura: se resuelve porque routes/__init__.py +# ya terminó de crear `api` antes de llegar a esta línea +from api.routes import api + + +@api.route('/post', methods=['POST']) +@jwt_required() +def create_post(): + + content = request.form.get('content') + foro_id = request.form.get('foro_id') + title = request.form.get('title') + + if not title: + return jsonify({ + "msg": "Title is required" + }), 400 + + if not content: + return jsonify({ + "msg": "Content is required" + }), 400 + + img_url = None + + if 'img' in request.files: + + file_to_upload = request.files['img'] + + if file_to_upload.filename != '': + + try: + + upload_result = cloudinary.uploader.upload( + file_to_upload, + upload_preset="neqycdyx" + ) + + + img_url = upload_result["secure_url"] + + except Exception as e: + + return jsonify({ + "msg": str(e) + }), 500 + + new_post = Post( + title=title, + content=content, + img=img_url, + foro_id=int(foro_id) if foro_id else None, + user_id=int(get_jwt_identity()) + ) + + db.session.add(new_post) + db.session.commit() + + return jsonify({ + "msg": "Post created", + "post": new_post.serialize_post() + }), 201 + + +@api.route('/post/', methods=['GET']) +def get_post_id(post_id): + + post = Post.query.get(post_id) + + if post is None: + return jsonify({ + "msg": "Post not found" + }), 404 + + return jsonify( + post.serialize_post() + ), 200 + + +@api.route('/foro//posts', methods=['GET']) +def get_posts_by_foro(foro_id): + + posts = Post.query.filter_by( + foro_id=foro_id + ).order_by( + Post.created_at.desc() + ).all() + + return jsonify([ + post.serialize_post() + for post in posts + ]), 200 + + +@api.route('/foro//posts/top', methods=['GET']) +def get_top_three_posts(foro_id): + + posts = Post.query.filter_by( + foro_id=foro_id + ).order_by( + Post.created_at.desc() + ).limit(3).all() + + return jsonify([ + post.serialize_post() + for post in posts + ]), 200 diff --git a/src/api/routes/routes_tag.py b/src/api/routes/routes_tag.py new file mode 100644 index 0000000000..0dd468c4fb --- /dev/null +++ b/src/api/routes/routes_tag.py @@ -0,0 +1,115 @@ +from flask import request, jsonify +from flask_jwt_extended import jwt_required, get_jwt_identity +from api.models.model_tag import Tag +from api.models.models_user import User +from api.models.models_foro import Foro +from api.models.model_select_tag import Tag_select +from api.database.db import db +from . import api + +#para uso de creacion de una tag personalizada para que usuariO la cree +# @api.route('/tag', methods=["POST"]) +# @jwt_required() +# def create_tag(): +# data = request.get_json() + +# title = data.get("title") + +# if title is None: +# return jsonify({"msg": "Title is required"}), 400 + +# title_check = title.strip().lower() + +# tag_check = db.session.execute(db.select(Tag).where(Tag.title == title_check)).scalar_one_or_none() +# if tag_check is not None: +# return jsonify({"msg": "That tag is already registered"}), 400 + +# new_tag = Tag(title=title_check, user_id = get_jwt_identity()) +# db.session.add(new_tag) +# db.session.commit() + +# return jsonify({"msg": "Tag created", 'tag': new_tag.serialize_tag()}), 201 + +@api.route('/tag', methods=["GET"]) +@jwt_required() +def all_tags(): + tag_list = db.session.query(Tag).all() + tag = list(map(lambda tag: tag.serialize_tag(), tag_list)) + + response_body = {"tag": tag} + + return jsonify(response_body), 200 + +@api.route('/tag-select', methods=["POST"]) +@jwt_required() +def select_tag(): + user_token = get_jwt_identity() + + data = request.get_json() + tags_id = data.get('tags_id') + user_id = int(user_token) + + + if tags_id is None: + return jsonify({"msg": "Bad request i need tag_id"}), 400 + + for tag in tags_id: + + tag_exists = db.session.get(Tag, tag) + if tag_exists is None: + return jsonify({"msg": "Tag not found"}), 404 + + new_tag_select = Tag_select(tag_id=tag, user_id=user_id) + db.session.add(new_tag_select) + db.session.commit() + + return jsonify({"msg": "Tag assigned", "Tag_select":new_tag_select.serialize_tag_user()}), 201 + +@api.route('/tag-select-foro', methods=["POST"]) +@jwt_required() +def select_tagForo(): + # user_token = get_jwt_identity() + # user_id = int(user_token) + + data = request.get_json() + foro_id = data.get('foro_id') + tags_id = data.get('tags_id') + + if tags_id is None: + return jsonify({"msg": "Bad request i need tag_id"}), 400 + + for tag in tags_id: + + tag_exists = db.session.get(Tag, tag) + if tag_exists is None: + return jsonify({"msg": "Tag not found"}), 404 + + new_tag_select = Tag_select(tag_id=tag, foro_id=foro_id) + db.session.add(new_tag_select) + db.session.commit() + + return jsonify({"msg": "Tag assigned", "Tag_select":new_tag_select.serialize_tag_foro()}), 201 + +@api.route('/tag/user/', methods=['GET']) +@jwt_required() +def get_tag_from_user(user_id): + user = db.session.get(User, user_id) + if user is None: + return jsonify({"msg": "User not found"}), 400 + query= db.select(Tag_select).where(Tag_select.user_id == user_id) + tag_select_list = db.session.execute(query).scalars().all() + tags = list(map(lambda Tag_select: Tag_select.serialize_tag_user(), tag_select_list)) + + return jsonify ({"user_id": user_id, "tags": tags}) + +@api.route('/tag/foro/', methods=['GET']) +@jwt_required() +def get_tag_from_foro(foro_id): + foro = db.session.get(Foro, foro_id) + if foro is None: + return jsonify({"msg": "Foro not found"}), 400 + query= db.select(Tag_select).where(Tag_select.foro_id == foro_id) + tag_select_list = db.session.execute(query).scalars().all() + tags = list(map(lambda Tag_select: Tag_select.serialize_tag_foro(), tag_select_list)) + + return jsonify ({"foro_id": foro_id, "tags": tags}) \ No newline at end of file diff --git a/src/api/routes/routes_user.py b/src/api/routes/routes_user.py new file mode 100644 index 0000000000..0185662563 --- /dev/null +++ b/src/api/routes/routes_user.py @@ -0,0 +1,141 @@ +from flask import request, jsonify +from flask_jwt_extended import create_access_token, get_jwt_identity, jwt_required +from api.models.models_user import User +from api.database.db import db +from api.extension import bcrypt +from api.service.save_img import save_img +from . import api + + +@api.route('/login', methods=['POST']) +def login(): + + body = request.get_json() + + if not body: + return jsonify({ + "success": False, + "msg": "Datos inválidos" + }), 400 + + email = body.get("email") + password = body.get("password") + + if not email or not password: + return jsonify({ + "success": False, + "msg": "Email y contraseña son obligatorios" + }), 400 + + user = User.query.filter_by(email=email).first() + + if not user: + return jsonify({ + "success": False, + "msg": "Credenciales incorrectas" + }), 401 + + if not bcrypt.check_password_hash(user.password, password): + return jsonify({ + "success": False, + "msg": "Credenciales incorrectas" + }), 401 + + token = create_access_token( + identity=str(user.id) + ) + + return jsonify({ + "success": True, + "token": token, + "user": { + "id": user.id, + "username": user.username, + "email": user.email + } + }), 200 + + +@api.route('/register', methods=['POST']) +def register_user(): + data = request.get_json() + email = data.get('email') + password = data.get('password') + username = data.get('username') + + if email is None or password is None or username is None: + return jsonify({"msg": "i need email, password and username"}), 400 + + email_check = db.session.execute(db.select(User).where(User.email == email)).scalar_one_or_none() + if email_check is not None: + return jsonify({"msg": "That email is already registered"}), 400 + + username_check = db.session.execute(db.select(User).where(User.username == username)).scalar_one_or_none() + if username_check is not None: + return jsonify({"msg": "That username is already taken"}), 400 + + hashed_password = bcrypt.generate_password_hash(password).decode('utf-8') + + + new_user = User(email=email, password=hashed_password, username=username, is_active=True) + db.session.add(new_user) + db.session.commit() + token = create_access_token(identity=str(new_user.id)) + + return jsonify({"msg": "User created", "token": token, 'user': new_user.serialize()}), 201 + + +@api.route('/profile/eddit/', methods=['PUT']) +@jwt_required() +def profile_edit(id): + + user_token = get_jwt_identity() + user_id = int(user_token) + + if user_id != id: + return jsonify({"msg": "You do not have permission to update this profile"}), 403 + + user_update = db.session.get(User, id) + + if user_update is None: + return jsonify({"msg": "User not found"}), 404 + + if 'img' in request.files: + file_to_upload = request.files['img'] + img_url = save_img(file_to_upload) + if isinstance(img_url, str): + user_update.img = img_url + + user_update.username = request.form.get('username', user_update.username) + user_update.email = request.form.get('email', user_update.email) + user_update.first_name = request.form.get('first_name', user_update.first_name) + user_update.last_name = request.form.get('last_name', user_update.last_name) + user_update.date_birth = request.form.get('date_birth', user_update.date_birth) + user_update.description = request.form.get('description', user_update.description) + + password = request.form.get('password') + + if password is not None: + hashed_password = bcrypt.generate_password_hash(password['password']).decode('utf-8') + user_update.password = hashed_password + + db.session.commit() + return jsonify({"msg": "User updated", "user": user_update.serialize_all()}), 200 + +@api.route("/profile/", methods=["GET"]) +@jwt_required() +def get_user_id(id): + + user_token = get_jwt_identity() + user_id = int(user_token) + + if user_id != id: + return jsonify({"msg": "You do not have permission to view this profile"}), 403 + + user = db.session.get(User, id) + + if user is None: + return jsonify({"msg": "User not found" }), 400 + + return jsonify(user.serialize_all()), 200 + diff --git a/src/api/service/save_img.py b/src/api/service/save_img.py new file mode 100644 index 0000000000..e67e3b9796 --- /dev/null +++ b/src/api/service/save_img.py @@ -0,0 +1,13 @@ +from flask import request, jsonify +import cloudinary +import cloudinary.uploader +from api.cloudinary.cloudinary_config import * + + +def save_img(file): + if file.filename != '': + try: + upload_result = cloudinary.uploader.upload(file) + return upload_result["secure_url"] + except Exception as e: + return jsonify({"msg": str(e)}), 500 \ No newline at end of file diff --git a/src/app.py b/src/app.py index 1b3340c0fa..d9a54019c9 100644 --- a/src/app.py +++ b/src/app.py @@ -1,72 +1,104 @@ """ This module takes care of starting the API Server, Loading the DB and Adding the endpoints """ + import os -from flask import Flask, request, jsonify, url_for, send_from_directory +from flask import Flask, jsonify, send_from_directory +from flask_cors import CORS from flask_migrate import Migrate -from flask_swagger import swagger +from flask_jwt_extended import JWTManager from api.utils import APIException, generate_sitemap -from api.models import db +from api.models.models_user import db from api.routes import api from api.admin import setup_admin from api.commands import setup_commands +from api.extension import bcrypt -# from models import Person ENV = "development" if os.getenv("FLASK_DEBUG") == "1" else "production" -static_file_dir = os.path.join(os.path.dirname( - os.path.realpath(__file__)), '../dist/') + +static_file_dir = os.path.join( + os.path.dirname(os.path.realpath(__file__)), + "../dist/" +) + app = Flask(__name__) app.url_map.strict_slashes = False -# database condiguration +CORS(app) +# JWT +#app.config["JWT_SECRET_KEY"] = "mindfed-secret-key" +app.config["JWT_SECRET_KEY"] = os.getenv("FLASK_JWT") +# db.init_app(app) +jwt = JWTManager(app) +bcrypt.init_app(app) + + +# Database db_url = os.getenv("DATABASE_URL") -if db_url is not None: - app.config['SQLALCHEMY_DATABASE_URI'] = db_url.replace( - "postgres://", "postgresql://") + +if db_url: + app.config["SQLALCHEMY_DATABASE_URI"] = db_url.replace( + "postgres://", + "postgresql://" + ) else: - app.config['SQLALCHEMY_DATABASE_URI'] = "sqlite:////tmp/test.db" + app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:////tmp/test.db" + +app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False -app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False -MIGRATE = Migrate(app, db, compare_type=True) db.init_app(app) +Migrate(app, db, compare_type=True) -# add the admin +# Admin setup_admin(app) -# add the admin +# Commands setup_commands(app) -# Add all endpoints form the API with a "api" prefix -app.register_blueprint(api, url_prefix='/api') - -# Handle/serialize errors like a JSON object +# API +app.register_blueprint(api, url_prefix="/api") @app.errorhandler(APIException) def handle_invalid_usage(error): return jsonify(error.to_dict()), error.status_code -# generate sitemap with all your endpoints - -@app.route('/') +@app.route("/") def sitemap(): if ENV == "development": return generate_sitemap(app) - return send_from_directory(static_file_dir, 'index.html') -# any other endpoint will try to serve it like a static file -@app.route('/', methods=['GET']) + return send_from_directory( + static_file_dir, + "index.html" + ) + + +@app.route("/", methods=["GET"]) def serve_any_other_file(path): - if not os.path.isfile(os.path.join(static_file_dir, path)): - path = 'index.html' - response = send_from_directory(static_file_dir, path) - response.cache_control.max_age = 0 # avoid cache memory + + if not os.path.isfile( + os.path.join(static_file_dir, path) + ): + path = "index.html" + + response = send_from_directory( + static_file_dir, + path + ) + + response.cache_control.max_age = 0 + return response -# this only runs if `$ python src/main.py` is executed -if __name__ == '__main__': - PORT = int(os.environ.get('PORT', 3001)) - app.run(host='0.0.0.0', port=PORT, debug=True) +if __name__ == "__main__": + PORT = int(os.environ.get("PORT", 3001)) + + app.run( + host="0.0.0.0", + port=PORT, + debug=True + ) \ No newline at end of file diff --git a/src/css/Landing.css b/src/css/Landing.css new file mode 100644 index 0000000000..913bde9bbd --- /dev/null +++ b/src/css/Landing.css @@ -0,0 +1,73 @@ +.landing-page { + background-color: #f8fafc; +} + +.landing-left { + padding-right: 3rem; +} + +.landing-title { + font-size: 3.5rem; + font-weight: 700; + color: #0f172a; + margin-bottom: 1rem; +} + +.landing-description { + font-size: 1.2rem; + color: #64748b; + margin-bottom: 2rem; +} + +.features { + margin-top: 2rem; +} + +.feature-item { + display: flex; + align-items: center; + margin-bottom: 1.5rem; + font-size: 1.1rem; + color: #1e293b; +} + +.feature-icon { + color: #2563eb; + font-size: 1.5rem; + margin-right: 15px; +} + +.login-card { + width: 450px; + background: white; + padding: 3rem; + border-radius: 20px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08); +} + +.login-title { + text-align: center; + margin-bottom: 2rem; + color: #0f172a; +} + +@media (max-width: 992px) { + + .landing-left { + text-align: center; + padding-right: 0; + margin-bottom: 3rem; + } + + .feature-item { + justify-content: center; + } + + .login-card { + width: 100%; + } + + .landing-title { + font-size: 2.5rem; + } +} \ No newline at end of file diff --git a/src/css/Navbar.css b/src/css/Navbar.css new file mode 100644 index 0000000000..6935d2332b --- /dev/null +++ b/src/css/Navbar.css @@ -0,0 +1,17 @@ +.mindfed-navbar { + background-color: #ffffff; + border-bottom: 1px solid #e5e7eb; + height: 90px; + display: flex; + align-items: center; +} + +.mindfed-logo { + height: 60px; + object-fit: contain; + transition: transform 0.3s ease; +} + +.mindfed-logo:hover { + transform: scale(1.05); +} \ No newline at end of file diff --git a/src/css/Post.css b/src/css/Post.css new file mode 100644 index 0000000000..80e49e61ce --- /dev/null +++ b/src/css/Post.css @@ -0,0 +1,66 @@ +.creacion-post-container { + min-height: 85vh; + display: flex; + align-items: center; + justify-content: center; +} + +.creacion-post-card { + border: none; + border-radius: 20px; + overflow: hidden; + transition: all 0.3s ease; +} + +.creacion-post-card:hover { + transform: translateY(-4px); +} + +.creacion-post-title { + color: #0d6efd; + letter-spacing: 1px; +} + +.creacion-post-input, +.creacion-post-textarea { + border-radius: 12px; +} + +.creacion-post-textarea { + resize: none; + min-height: 150px; +} + +.preview-image { + max-height: 250px; + width: auto; + object-fit: cover; + border-radius: 12px; +} + +.btn-publicar, +.btn-cancelar { + min-width: 130px; + font-weight: 600; +} + +.btn-publicar { + transition: all 0.3s ease; +} + +.btn-publicar:hover { + transform: translateY(-2px); +} + +.btn-cancelar { + transition: all 0.3s ease; +} + +.btn-cancelar:hover { + transform: translateY(-2px); +} + +.form-control:focus { + border-color: #0d6efd; + box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.15); +} \ No newline at end of file diff --git a/src/front/assets/img/logomin.png b/src/front/assets/img/logomin.png new file mode 100644 index 0000000000..51df5caf7e Binary files /dev/null and b/src/front/assets/img/logomin.png differ diff --git a/src/front/components/BodyTag.jsx b/src/front/components/BodyTag.jsx new file mode 100644 index 0000000000..0d53e16f11 --- /dev/null +++ b/src/front/components/BodyTag.jsx @@ -0,0 +1,17 @@ + + + +export const BodyTag = ({tag, onSelectedTag, isSelected}) =>{ + const onClick = (e)=> { + e.preventDefault() + onSelectedTag(tag) + } + return( +
+ +
{tag.title}
+
+ ) +} \ No newline at end of file diff --git a/src/front/components/Navbar.jsx b/src/front/components/Navbar.jsx index 30d43a2636..11ccbfe452 100644 --- a/src/front/components/Navbar.jsx +++ b/src/front/components/Navbar.jsx @@ -1,19 +1,18 @@ -import { Link } from "react-router-dom"; +import logo from "../assets/img/logomin.png"; +import "../../css/Navbar.css"; export const Navbar = () => { + return ( + + ); }; \ No newline at end of file diff --git a/src/front/components/Post/Createpost.jsx b/src/front/components/Post/Createpost.jsx new file mode 100644 index 0000000000..647f0d8514 --- /dev/null +++ b/src/front/components/Post/Createpost.jsx @@ -0,0 +1,135 @@ +import { useCreatePost } from "../../hooks/Hooks_post/useCreatePost"; +import "../../../css/Post.css"; + +export const CreatePost = ({ forumId }) => { + + const { + title, + setTitle, + content, + setContent, + setImg, + preview, + setPreview, + success, + handleSubmit + } = useCreatePost(forumId); + + return ( +
+ +
+ +
+ +
+ +
+ +

+ CREACIÓN POST +

+ + {success && ( // ← nuevo +
+ ✅ ¡Post creado exitosamente! +
+ )} + + + + setTitle(e.target.value) + } + /> + + { + + const file = + e.target.files[0]; + + setImg(file); + + if (file) { + setPreview( + URL.createObjectURL(file) + ); + } + }} + /> + + { + preview && ( +
+ + preview + +
+ ) + } + + +
+
+
+ + +
+ +
+ {store.tags && store.tags.map((tag) => + + )} +
+
+ +
+
+ + Back Home + +
+ +
+ +
+
+ + + +
+ ); +}; \ No newline at end of file diff --git a/src/front/pages/DataProfile.jsx b/src/front/pages/DataProfile.jsx new file mode 100644 index 0000000000..5e998f5d40 --- /dev/null +++ b/src/front/pages/DataProfile.jsx @@ -0,0 +1,159 @@ +import React from "react"; +import { useEffect } from "react"; +import { useProfile } from "../hooks/useProfile"; +import { useTag } from "../hooks/useTag"; +import { BodyTag } from "../components/BodyTag.jsx"; +import useGlobalReducer from "../hooks/useGlobalReducer.jsx"; + + +export const DataProfile = () => { + + const { store, dispatch } = useGlobalReducer() + + const { getDataTag, onSelectedTag, handleSave, selectedTag } = useTag(); + + useEffect(() => { + getDataTag(); + }, []); + + const { + firstName, setFirstName, + lastName, setLastName, + birthDate, setBirthDate, + profileImg, setProfileImg, + description, setDescription, + error, + loading, + success, + handleUpdateProfile + } = useProfile(); + + const saveProfile = async (e) => { + e.preventDefault() + try { + const res = await handleUpdateProfile(); + const resTag = await handleSave(); + } catch (err) { + console.error("Error to save profile", err); + } + } + + return ( +
+
+
+
+

Completa tu Perfil

+ + {error &&
{error}
} + {success &&
¡Perfil actualizado con éxito!
} + +
+
+
+
+ + setFirstName(e.target.value)} + /> +
+
+ + setLastName(e.target.value)} + /> +
+
+ +
+ + setBirthDate(e.target.value)} + /> +
+ +
+ {profileImg ? ( + Foro preview { + e.target.src = "https://placehold.co/65?text=Foro"; + }} + />) : (
+ 💬 +
)} + + setProfileImg(e.target.files[0])} + /> +
+ +
+ + +
+ +
+ +
+ {store.tags && store.tags.map((tag) => { + const handleSelected = selectedTag.includes(tag.id); + return (); + })} +
+
+ +
+ +
+
+
+
+
+
+ ); +}; \ No newline at end of file diff --git a/src/front/pages/Home.jsx b/src/front/pages/Home.jsx deleted file mode 100644 index 341ed21768..0000000000 --- a/src/front/pages/Home.jsx +++ /dev/null @@ -1,52 +0,0 @@ -import React, { useEffect } from "react" -import rigoImageUrl from "../assets/img/rigo-baby.jpg"; -import useGlobalReducer from "../hooks/useGlobalReducer.jsx"; - -export const Home = () => { - - const { store, dispatch } = useGlobalReducer() - - const loadMessage = async () => { - try { - const backendUrl = import.meta.env.VITE_BACKEND_URL - - if (!backendUrl) throw new Error("VITE_BACKEND_URL is not defined in .env file") - - const response = await fetch(backendUrl + "/api/hello") - const data = await response.json() - - if (response.ok) dispatch({ type: "set_hello", payload: data.message }) - - return data - - } catch (error) { - if (error.message) throw new Error( - `Could not fetch the message from the backend. - Please check if the backend is running and the backend port is public.` - ); - } - - } - - useEffect(() => { - loadMessage() - }, []) - - return ( -
-

Hello Rigo!!

-

- Rigo Baby -

-
- {store.message ? ( - {store.message} - ) : ( - - Loading message from the backend (make sure your python 🐍 backend is running)... - - )} -
-
- ); -}; \ No newline at end of file diff --git a/src/front/pages/Landing.jsx b/src/front/pages/Landing.jsx new file mode 100644 index 0000000000..ae75d0f154 --- /dev/null +++ b/src/front/pages/Landing.jsx @@ -0,0 +1,126 @@ +import { Link } from "react-router-dom"; +import { useLogin } from "../hooks/useLogin"; +import { + FaUsers, + FaComments, + FaGlobe, + FaLightbulb +} from "react-icons/fa"; + +import "../../css/Landing.css" + +export const Landing = () => { + + const { + email, + setEmail, + password, + setPassword, + error, + handleLogin + } = useLogin(); + + return ( +
+ +
+ +
+ {/* Parte Izquierda */} + +
+ +

+ Bienvenido a MindFed +

+ +

+ La comunidad donde puedes compartir ideas, + debatir y conectar con personas que comparten + tus mismos intereses. +

+ +
+ +
+ + Comunidades activas +
+ +
+ + Debates en tiempo real +
+ +
+ + Películas, deportes, videojuegos y más +
+ +
+ + Comparte tus ideas +
+ +
+ +
+ + {/* Parte Derecha */} + +
+ +
+ +

+ Iniciar Sesión +

+ + setEmail(e.target.value)} + /> + + setPassword(e.target.value)} + /> + + { + error && ( +
+ {error} +
+ ) + } + + + + + Registrarse + + +
+ +
+ +
+ +
+ +
+ ); +}; \ No newline at end of file diff --git a/src/front/pages/RegisterForm.jsx b/src/front/pages/RegisterForm.jsx new file mode 100644 index 0000000000..6c86859ef7 --- /dev/null +++ b/src/front/pages/RegisterForm.jsx @@ -0,0 +1,100 @@ +import React, { useState } from "react"; +import { useRegister } from "../hooks/useRegister"; +import { Link } from "react-router-dom"; + +export const RegisterForm = () => { + const { + username, + setUsername, + email, + setEmail, + password, + setPassword, + error, + handleRegister + } = useRegister(); + + const handleSubmit = (event) => { + event.preventDefault(); + handleRegister(); + }; + + return ( +
+
+
+
👤
+

Create Account

+
+ +
+ {error && ( +
+ {error} +
+ )} + +
+
+ setUsername(e.target.value)} + required + /> + +
+ +
+ setEmail(e.target.value)} + required + /> + +
+ We'll never share your email with anyone. +
+
+ +
+ setPassword(e.target.value)} + required + /> + +
+ + +
+
+ +
+

+ Already have an account? Log In +

+
+
+
+ ); +}; \ No newline at end of file diff --git a/src/front/routes.jsx b/src/front/routes.jsx index 0557df6141..9338c372da 100644 --- a/src/front/routes.jsx +++ b/src/front/routes.jsx @@ -6,9 +6,14 @@ import { Route, } from "react-router-dom"; import { Layout } from "./pages/Layout"; -import { Home } from "./pages/Home"; +import { Landing } from "./pages/Landing"; +import { CreacionPost } from "./pages/CreacionPost"; import { Single } from "./pages/Single"; import { Demo } from "./pages/Demo"; +import { RegisterForm } from "./pages/RegisterForm" +import { DataProfile } from "./pages/DataProfile" +import { CreateForo } from "./pages/CreateForo"; + export const router = createBrowserRouter( createRoutesFromElements( @@ -19,12 +24,16 @@ export const router = createBrowserRouter( // Note: The child paths of the Layout element replace the Outlet component with the elements contained in the "element" attribute of these child paths. // Root Route: All navigation will start from here. + <> + } /> } errorElement={

Not found!

} > - {/* Nested Routes: Defines sub-routes within the BaseHome component. */} - } /> } /> {/* Dynamic route for single items */} - } /> + } /> + } /> + } /> + } /> + ) ); \ No newline at end of file diff --git a/src/front/store.js b/src/front/store.js index 3062cd222d..7355a01b65 100644 --- a/src/front/store.js +++ b/src/front/store.js @@ -1,38 +1,19 @@ export const initialStore=()=>{ return{ - message: null, - todos: [ - { - id: 1, - title: "Make the bed", - background: null, - }, - { - id: 2, - title: "Do my homework", - background: null, - } - ] + tags: [] } } export default function storeReducer(store, action = {}) { switch(action.type){ - case 'set_hello': - return { + case 'all_tags': + return { ...store, - message: action.payload - }; - - case 'add_task': + tags: action.payload + }; - const { id, color } = action.payload - - return { - ...store, - todos: store.todos.map((todo) => (todo.id === id ? { ...todo, background: color } : todo)) - }; + default: - throw Error('Unknown action.'); + throw Error('Unknown action.'); } } diff --git a/src/services/authService.js b/src/services/authService.js new file mode 100644 index 0000000000..ff531afa49 --- /dev/null +++ b/src/services/authService.js @@ -0,0 +1,61 @@ +const BACKEND_URL = import.meta.env.VITE_BACKEND_URL; + +export const loginUser = async (email, password) => { + try { + const response = await fetch(`${BACKEND_URL}/api/login`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email, + password, + }), + }); + + const data = await response.json(); + + return data; + } catch (error) { + console.error("Error en login:", error); + return null; + } +}; + +export const registerUser = async (email, password, username) => { + if (!email?.trim() || !password?.trim()) { + throw new Error("Email and password are required."); + } + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + throw new Error("Invalid email format."); + } + + if (password.length < 6) { + throw new Error("Password must be at least 6 characters long."); + } + + try { + const response = await fetch(`${BACKEND_URL}/api/register`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email, password, username }), + }); + + const textData = await response.text(); + const data = textData ? JSON.parse(textData) : {}; + + if (!response.ok) { + const errorMessage = data.message || `Registration failed (Status: ${response.status})`; + throw new Error(errorMessage); + } + + return data; + } catch (error) { + console.error("Error in registerUser:", error.message); + throw error; + } +}; \ No newline at end of file diff --git a/src/services/createForoService.js b/src/services/createForoService.js new file mode 100644 index 0000000000..85dad4e137 --- /dev/null +++ b/src/services/createForoService.js @@ -0,0 +1,81 @@ +const BACKEND_URL = import.meta.env.VITE_BACKEND_URL; + +export const createForo = async (foroData) => { + const token = localStorage.getItem("token"); + + if (!token) { + throw new Error("No se encontró el token de autorización. Por favor, inicia sesión."); + } + + try { + const formData = new FormData(); + formData.append("title", foroData.title); + formData.append("description", foroData.description || ""); + if (foroData.img) { + formData.append("img", foroData.img); + } + + const response = await fetch(`${BACKEND_URL}/api/foro`, { + method: "POST", + headers: { + "Authorization": `Bearer ${token}` + + }, + body: formData + }); + + const textData = await response.text(); + const data = textData ? JSON.parse(textData) : {}; + + if (!response.ok) { + const errorMessage = data.msg || `Error al crear el foro (Estado: ${response.status})`; + throw new Error(errorMessage); + } + + return data; + } catch (error) { + throw error; + } +}; + +export const getForos = async () => { + try { + const response = await fetch(`${BACKEND_URL}/api/foros`, { + method: "GET", + headers: { + "Content-Type": "application/json" + } + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.msg || "Error al obtener los foros"); + } + + return data; + } catch (error) { + throw error; + } +}; + +export const getForoById = async (foroId) => { + try { + const response = await fetch(`${BACKEND_URL}/api/foro/${foroId}`, { + method: "GET", + headers: { + "Content-Type": "application/json" + } + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.msg || "Foro no encontrado"); + } + + return data; + } catch (error) { + throw error; + } +}; \ No newline at end of file diff --git a/src/services/postService.js b/src/services/postService.js new file mode 100644 index 0000000000..e8871a7b4d --- /dev/null +++ b/src/services/postService.js @@ -0,0 +1,37 @@ +const BASE_URL = import.meta.env.VITE_BACKEND_URL; + +export const createPost = async (formData, token) => { + const response = await fetch(`${BASE_URL}/api/post`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}` + }, + body: formData + }); + + return await response.json(); +}; + +export const getPostsForum = async (forumId) => { + const response = await fetch( + `${BASE_URL}/api/foro/${forumId}/posts` + ); + + return await response.json(); +}; + +export const getTopPosts = async (forumId) => { + const response = await fetch( + `${BASE_URL}/api/foro/${forumId}/posts/top` + ); + + return await response.json(); +}; + +export const getPostById = async (postId) => { + const response = await fetch( + `${BASE_URL}/api/post/${postId}` + ); + + return await response.json(); +}; \ No newline at end of file diff --git a/src/services/profileService.js b/src/services/profileService.js new file mode 100644 index 0000000000..43ffc7c8e4 --- /dev/null +++ b/src/services/profileService.js @@ -0,0 +1,45 @@ +const BACKEND_URL = import.meta.env.VITE_BACKEND_URL; + +export const updateProfile = async (userId, profileData) => { + if (!userId) { + throw new Error("El ID de usuario es obligatorio para actualizar el perfil."); + } + + const token = localStorage.getItem("token"); + + if (!token) { + throw new Error("No se encontró el token de autorización. Por favor, inicia sesión."); + } + + try { + const formData = new FormData(); + formData.append("first_name", profileData.firstName); + formData.append("last_name", profileData.lastName); + formData.append("date_birth", profileData.birthDate); + formData.append("description", profileData.description); + if (profileData.profileImg){ + formData.append("img", profileData.profileImg) + } + + const response = await fetch(`${BACKEND_URL}/api/profile/eddit/${userId}`, { + method: "PUT", + headers: { + "Authorization": `Bearer ${token}` + }, + body: formData + }); + + const textData = await response.text(); + const data = textData ? JSON.parse(textData) : {}; + + if (!response.ok) { + const errorMessage = data.msg || `Error en la actualización (Estado: ${response.status})`; + throw new Error(errorMessage); + } + + return data; + } catch (error) { + console.error("Error en updateProfile:", error.message); + throw error; + } +}; \ No newline at end of file diff --git a/src/services/tagService.js b/src/services/tagService.js new file mode 100644 index 0000000000..8ffd7cb5f9 --- /dev/null +++ b/src/services/tagService.js @@ -0,0 +1,65 @@ +const BACKEND_URL = import.meta.env.VITE_BACKEND_URL; + +export const getTags = async (dispatch) => { + try { + const token = localStorage.getItem("token"); + const res = await fetch(`${BACKEND_URL}/api/tag`, { + method: "GET", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${token}`, + } + }); + const data = await res.json(); + dispatch({ + type: "all_tags", + payload: data.tag + }); + } catch (err) { + console.error("Error to get tags", err); + } +}; + +export const selectTagFromUser = async (selectTags) => { + try { + const token = localStorage.getItem("token"); + const res = await fetch(`${BACKEND_URL}/api/tag-select`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${token}`, + }, + body: JSON.stringify({ + tags_id: selectTags + }) + }); + const data = await res.json(); + } catch (err) { + console.error("Error to get tags", err); + } +} + +export const selectTagFromForo = async (foroId, tagsId) => { + try { + const token = localStorage.getItem("token"); + const res = await fetch(`${BACKEND_URL}/api/tag-select-foro`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${token}`, + }, + body: JSON.stringify({ + foro_id: foroId, + tags_id: tagsId + }) + }); + const data = await res.json(); + if (!res.ok) { + throw new Error(data.msg || "Error al asignar tags al foro"); + } + return data; + } catch (err) { + console.error("Error to get tags", err); + throw err; + } +} \ No newline at end of file